@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.1

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 (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
package/src/util/git.mjs CHANGED
@@ -1,519 +1,562 @@
1
- import { spawn } from "node:child_process"
2
- import { mkdtemp, writeFile, unlink, rmdir } from "node:fs/promises"
3
- import { tmpdir } from "node:os"
4
- import path from "node:path"
5
-
6
- const GIT_TIMEOUT_MS = 30000
7
-
8
- function run(args, cwd = process.cwd(), timeoutMs = GIT_TIMEOUT_MS, env = {}) {
9
- return new Promise((resolve) => {
10
- let stdout = ""
11
- let stderr = ""
12
- let done = false
13
-
14
- const child = spawn("git", args, {
15
- cwd,
16
- windowsHide: true,
17
- stdio: ["ignore", "pipe", "pipe"],
18
- env: { ...process.env, ...env }
19
- })
20
-
21
- const timer = setTimeout(() => {
22
- done = true
23
- child.kill()
24
- resolve({ ok: false, stdout, stderr: "git command timed out", code: null })
25
- }, timeoutMs)
26
-
27
- child.stdout.on("data", (buf) => { stdout += String(buf) })
28
- child.stderr.on("data", (buf) => { stderr += String(buf) })
29
-
30
- child.on("error", (error) => {
31
- if (done) return
32
- done = true
33
- clearTimeout(timer)
34
- resolve({ ok: false, stdout, stderr: error.message, code: null })
35
- })
36
-
37
- child.on("close", (code) => {
38
- if (done) return
39
- done = true
40
- clearTimeout(timer)
41
- resolve({ ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code })
42
- })
43
- })
44
- }
45
-
46
- /** Check if cwd is inside a git repo */
47
- export async function isGitRepo(cwd = process.cwd()) {
48
- const result = await run(["rev-parse", "--is-inside-work-tree"], cwd)
49
- return result.ok && result.stdout.trim() === "true"
50
- }
51
-
52
- /** Get current branch name */
53
- export async function currentBranch(cwd = process.cwd()) {
54
- const result = await run(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
55
- return result.ok ? result.stdout.trim() : null
56
- }
57
-
58
- /** Check if working tree is clean */
59
- export async function isClean(cwd = process.cwd()) {
60
- const result = await run(["status", "--porcelain"], cwd)
61
- return result.ok && !result.stdout.trim()
62
- }
63
-
64
- /** Create and checkout a new branch */
65
- export async function createBranch(name, cwd = process.cwd()) {
66
- const result = await run(["checkout", "-b", name], cwd)
67
- return { ok: result.ok, message: result.ok ? `created branch: ${name}` : result.stderr }
68
- }
69
-
70
- /** Checkout an existing branch */
71
- export async function checkoutBranch(name, cwd = process.cwd()) {
72
- const result = await run(["checkout", name], cwd)
73
- return { ok: result.ok, message: result.ok ? `switched to: ${name}` : result.stderr }
74
- }
75
-
76
- /** Stage all changes and commit */
77
- export async function commitAll(message, cwd = process.cwd()) {
78
- const add = await run(["add", "-A"], cwd)
79
- if (!add.ok) return { ok: false, message: `git add failed: ${add.stderr}` }
80
- const commit = await run(["commit", "-m", message, "--allow-empty"], cwd)
81
- if (!commit.ok) {
82
- // Nothing to commit is not an error
83
- if (commit.stderr.includes("nothing to commit")) {
84
- return { ok: true, message: "nothing to commit", empty: true }
85
- }
86
- return { ok: false, message: `git commit failed: ${commit.stderr}` }
87
- }
88
- return { ok: true, message: commit.stdout.split("\n")[0] || "committed" }
89
- }
90
-
91
- /** Merge a branch into current branch */
92
- export async function mergeBranch(source, cwd = process.cwd()) {
93
- const result = await run(["merge", source, "--no-ff", "-m", `Merge branch '${source}'`], cwd)
94
- return { ok: result.ok, message: result.ok ? `merged ${source}` : result.stderr }
95
- }
96
-
97
- /** Delete a branch */
98
- export async function deleteBranch(name, cwd = process.cwd()) {
99
- const result = await run(["branch", "-d", name], cwd)
100
- return { ok: result.ok, message: result.ok ? `deleted branch: ${name}` : result.stderr }
101
- }
102
-
103
- /** Get short log of recent commits */
104
- export async function recentCommits(count = 5, cwd = process.cwd()) {
105
- const result = await run(["log", `--oneline`, `-${count}`], cwd)
106
- return result.ok ? result.stdout.trim().split("\n").filter(Boolean) : []
107
- }
108
-
109
- /** Get diff stat summary */
110
- export async function diffStat(cwd = process.cwd()) {
111
- const result = await run(["diff", "--stat", "HEAD"], cwd)
112
- return result.ok ? result.stdout.trim() : ""
113
- }
114
-
115
- /** Stash current changes */
116
- export async function stash(message = "auto-stash", cwd = process.cwd()) {
117
- const result = await run(["stash", "push", "-m", message], cwd)
118
- return { ok: result.ok, message: result.ok ? result.stdout.trim() : result.stderr }
119
- }
120
-
121
- /** Pop stash */
122
- export async function stashPop(cwd = process.cwd()) {
123
- const result = await run(["stash", "pop"], cwd)
124
- return { ok: result.ok, message: result.ok ? result.stdout.trim() : result.stderr }
125
- }
126
-
127
- /** Generate a branch name from session/objective */
128
- export function generateBranchName(sessionId, objective = "") {
129
- const prefix = "kkcode"
130
- const shortId = String(sessionId || "").slice(0, 8)
131
- const slug = String(objective || "")
132
- .toLowerCase()
133
- .replace(/[^a-z0-9\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]+/g, "-")
134
- .replace(/^-|-$/g, "")
135
- .slice(0, 40)
136
- return `${prefix}/${shortId}${slug ? "-" + slug : ""}`
137
- }
138
-
139
- // ============================================================================
140
- // Ghost Commit (幽灵提交) - AI Agent Git 自动化核心功能
141
- // ============================================================================
142
-
143
- /**
144
- * Ghost Commit 元数据结构
145
- * @typedef {Object} GhostCommitInfo
146
- * @property {string} id - 幽灵提交ID (UUID)
147
- * @property {string} commitHash - Git 提交对象哈希
148
- * @property {string} repoPath - 仓库绝对路径
149
- * @property {string} parentHash - 父提交哈希
150
- * @property {string} message - 提交信息
151
- * @property {number} createdAt - 创建时间戳
152
- * @property {string[]} files - 包含的文件列表
153
- */
154
-
155
- /**
156
- * 创建幽灵提交 (Ghost Commit)
157
- * 使用临时索引创建不引用在任何分支上的提交对象
158
- *
159
- * @param {string} repoPath - 仓库路径
160
- * @param {string} message - 提交信息
161
- * @param {string[]} [paths=[]] - 要包含的文件路径(相对于repoPath),空数组表示所有更改
162
- * @returns {Promise<{ok: boolean, ghostCommit?: GhostCommitInfo, error?: string}>}
163
- */
164
- export async function createGhostCommit(repoPath, message = "kkcode snapshot", paths = []) {
165
- // 检查是否是 Git 仓库
166
- if (!(await isGitRepo(repoPath))) {
167
- return { ok: false, error: "not a git repository" }
168
- }
169
-
170
- // 获取当前 HEAD
171
- const headResult = await run(["rev-parse", "HEAD"], repoPath)
172
- if (!headResult.ok) {
173
- return { ok: false, error: `failed to get HEAD: ${headResult.stderr}` }
174
- }
175
- const parentHash = headResult.stdout.trim()
176
-
177
- // 创建临时目录和临时索引文件
178
- let tmpDir = null
179
- let indexPath = null
180
-
181
- try {
182
- tmpDir = await mkdtemp(path.join(tmpdir(), "kkcode-git-"))
183
- indexPath = path.join(tmpDir, "index")
184
-
185
- // 1. 读取当前 HEAD 到临时索引
186
- const readTreeResult = await run(
187
- ["read-tree", "HEAD"],
188
- repoPath,
189
- GIT_TIMEOUT_MS,
190
- { GIT_INDEX_FILE: indexPath }
191
- )
192
- if (!readTreeResult.ok) {
193
- return { ok: false, error: `read-tree failed: ${readTreeResult.stderr}` }
194
- }
195
-
196
- // 2. 添加更改到临时索引
197
- const addArgs = paths.length > 0
198
- ? ["add", "--", ...paths]
199
- : ["add", "-A"]
200
- const addResult = await run(
201
- addArgs,
202
- repoPath,
203
- GIT_TIMEOUT_MS,
204
- { GIT_INDEX_FILE: indexPath }
205
- )
206
- if (!addResult.ok) {
207
- return { ok: false, error: `git add failed: ${addResult.stderr}` }
208
- }
209
-
210
- // 3. 写入树对象
211
- const writeTreeResult = await run(
212
- ["write-tree"],
213
- repoPath,
214
- GIT_TIMEOUT_MS,
215
- { GIT_INDEX_FILE: indexPath }
216
- )
217
- if (!writeTreeResult.ok) {
218
- return { ok: false, error: `write-tree failed: ${writeTreeResult.stderr}` }
219
- }
220
- const treeHash = writeTreeResult.stdout.trim()
221
-
222
- // 4. 创建提交对象 (幽灵提交)
223
- const commitTreeResult = await run(
224
- ["commit-tree", treeHash, "-p", parentHash, "-m", message],
225
- repoPath
226
- )
227
- if (!commitTreeResult.ok) {
228
- return { ok: false, error: `commit-tree failed: ${commitTreeResult.stderr}` }
229
- }
230
- const commitHash = commitTreeResult.stdout.trim()
231
-
232
- // 5. 获取包含的文件列表
233
- const diffResult = await run(
234
- ["diff-tree", "--no-commit-id", "--name-only", "-r", commitHash],
235
- repoPath
236
- )
237
- const files = diffResult.ok
238
- ? diffResult.stdout.trim().split("\n").filter(Boolean)
239
- : []
240
-
241
- return {
242
- ok: true,
243
- ghostCommit: {
244
- id: generateGhostCommitId(),
245
- commitHash,
246
- repoPath: path.resolve(repoPath),
247
- parentHash,
248
- message,
249
- createdAt: Date.now(),
250
- files
251
- }
252
- }
253
- } catch (error) {
254
- return { ok: false, error: error.message }
255
- } finally {
256
- // 清理临时目录
257
- if (tmpDir) {
258
- try {
259
- await rmdir(tmpDir, { recursive: true })
260
- } catch { /* ignore cleanup errors */ }
261
- }
262
- }
263
- }
264
-
265
- /**
266
- * 恢复到幽灵提交状态
267
- * 使用 git restore 将工作区恢复到幽灵提交的状态
268
- *
269
- * @param {string} repoPath - 仓库路径
270
- * @param {string} commitHash - 幽灵提交的 commit hash
271
- * @param {boolean} [restoreIndex=false] - 是否也恢复暂存区
272
- * @returns {Promise<{ok: boolean, message?: string, error?: string}>}
273
- */
274
- export async function restoreGhostCommit(repoPath, commitHash, restoreIndex = false) {
275
- // 验证提交对象存在
276
- const catFileResult = await run(["cat-file", "-t", commitHash], repoPath)
277
- if (!catFileResult.ok || catFileResult.stdout.trim() !== "commit") {
278
- return { ok: false, error: `invalid commit hash: ${commitHash}` }
279
- }
280
-
281
- // 恢复工作区
282
- const restoreArgs = ["restore", "--source", commitHash, "."]
283
- const restoreResult = await run(restoreArgs, repoPath)
284
- if (!restoreResult.ok) {
285
- return { ok: false, error: `restore failed: ${restoreResult.stderr}` }
286
- }
287
-
288
- // 如果需要,也恢复暂存区
289
- if (restoreIndex) {
290
- const readTreeResult = await run(["read-tree", commitHash], repoPath)
291
- if (!readTreeResult.ok) {
292
- return { ok: false, error: `restore index failed: ${readTreeResult.stderr}` }
293
- }
294
- }
295
-
296
- return { ok: true, message: `restored to ${commitHash.slice(0, 8)}` }
297
- }
298
-
299
- /**
300
- * 应用 Patch (AI 生成的 diff)
301
- * 支持 git apply --3way 进行三方合并
302
- *
303
- * @param {string} repoPath - 仓库路径
304
- * @param {string} diff - 统一格式的 diff 文本
305
- * @param {Object} options - 选项
306
- * @param {boolean} [options.threeway=true] - 使用三方合并
307
- * @param {boolean} [options.check=false] - 仅检查,不实际应用
308
- * @param {boolean} [options.whitespace="nowarn"] - 空白字符处理
309
- * @returns {Promise<{ok: boolean, applied?: string[], skipped?: string[], conflicts?: string[], error?: string}>}
310
- */
311
- export async function applyPatch(repoPath, diff, options = {}) {
312
- const {
313
- threeway = true,
314
- check = false,
315
- whitespace = "nowarn"
316
- } = options
317
-
318
- // 创建临时 patch 文件
319
- let patchPath = null
320
- try {
321
- const tmpDir = await mkdtemp(path.join(tmpdir(), "kkcode-patch-"))
322
- patchPath = path.join(tmpDir, "changes.patch")
323
- await writeFile(patchPath, diff, "utf8")
324
-
325
- // 构建 git apply 参数
326
- const applyArgs = ["apply"]
327
- if (threeway) applyArgs.push("--3way")
328
- if (check) applyArgs.push("--check")
329
- if (whitespace) applyArgs.push(`--whitespace=${whitespace}`)
330
- if (!check) applyArgs.push("-v") // verbose for parsing results
331
- applyArgs.push(patchPath)
332
-
333
- const result = await run(applyArgs, repoPath)
334
-
335
- // 解析结果
336
- if (!result.ok) {
337
- // 解析错误信息,提取冲突文件
338
- const conflictMatch = result.stderr.match(/error: patch failed: (.+)/g)
339
- const conflicts = conflictMatch
340
- ? conflictMatch.map(m => m.replace(/error: patch failed: /, "").split(":")[0])
341
- : []
342
-
343
- return {
344
- ok: false,
345
- error: result.stderr,
346
- conflicts
347
- }
348
- }
349
-
350
- // 解析成功应用的文件
351
- const appliedMatch = result.stdout.match(/Applied patch to (.+)/g)
352
- const applied = appliedMatch
353
- ? appliedMatch.map(m => m.replace(/Applied patch to /, "").trim())
354
- : []
355
-
356
- return {
357
- ok: true,
358
- applied,
359
- skipped: [],
360
- conflicts: []
361
- }
362
- } catch (error) {
363
- return { ok: false, error: error.message }
364
- } finally {
365
- // 清理临时文件
366
- if (patchPath) {
367
- try {
368
- const tmpDir = path.dirname(patchPath)
369
- await unlink(patchPath)
370
- await rmdir(tmpDir)
371
- } catch { /* ignore cleanup errors */ }
372
- }
373
- }
374
- }
375
-
376
- /**
377
- * 预检 Patch - 检查 patch 是否可以应用,不实际修改文件
378
- *
379
- * @param {string} repoPath - 仓库路径
380
- * @param {string} diff - 统一格式的 diff 文本
381
- * @returns {Promise<{applicable: boolean, conflicts?: string[], error?: string}>}
382
- */
383
- export async function preflightPatch(repoPath, diff) {
384
- const result = await applyPatch(repoPath, diff, { check: true })
385
- return {
386
- applicable: result.ok,
387
- conflicts: result.conflicts,
388
- error: result.error
389
- }
390
- }
391
-
392
- /**
393
- * 获取 Git 仓库信息
394
- * 收集当前仓库的上下文信息供 AI 使用
395
- *
396
- * @param {string} repoPath - 仓库路径
397
- * @returns {Promise<{ok: boolean, info?: Object, error?: string}>}
398
- */
399
- export async function getGitInfo(repoPath) {
400
- if (!(await isGitRepo(repoPath))) {
401
- return { ok: false, error: "not a git repository" }
402
- }
403
-
404
- try {
405
- // 并行获取各种信息
406
- const [
407
- branchResult,
408
- commitResult,
409
- remoteResult,
410
- statusResult,
411
- statusPorcelain
412
- ] = await Promise.all([
413
- run(["rev-parse", "--abbrev-ref", "HEAD"], repoPath),
414
- run(["rev-parse", "HEAD"], repoPath),
415
- run(["remote", "-v"], repoPath),
416
- run(["status", "--short"], repoPath),
417
- run(["status", "--porcelain"], repoPath)
418
- ])
419
-
420
- // 解析远程仓库信息
421
- const remotes = remoteResult.ok
422
- ? remoteResult.stdout.split("\n")
423
- .filter(line => line.includes("(fetch)"))
424
- .map(line => {
425
- const parts = line.split(/\s+/)
426
- return { name: parts[0], url: parts[1] }
427
- })
428
- : []
429
-
430
- // 解析状态
431
- const hasUncommittedChanges = statusPorcelain.ok && statusPorcelain.stdout.trim() !== ""
432
- const changedFiles = statusPorcelain.ok
433
- ? statusPorcelain.stdout.split("\n").filter(Boolean).map(line => ({
434
- status: line.slice(0, 2),
435
- path: line.slice(3)
436
- }))
437
- : []
438
-
439
- return {
440
- ok: true,
441
- info: {
442
- isGitRepo: true,
443
- currentBranch: branchResult.ok ? branchResult.stdout.trim() : null,
444
- currentCommit: commitResult.ok ? commitResult.stdout.trim() : null,
445
- remotes,
446
- hasUncommittedChanges,
447
- changedFiles,
448
- statusSummary: statusResult.ok ? statusResult.stdout : ""
449
- }
450
- }
451
- } catch (error) {
452
- return { ok: false, error: error.message }
453
- }
454
- }
455
-
456
- /**
457
- * 获取当前工作目录与指定提交的 diff
458
- *
459
- * @param {string} repoPath - 仓库路径
460
- * @param {string} [target="HEAD"] - 目标提交
461
- * @returns {Promise<{ok: boolean, diff?: string, error?: string}>}
462
- */
463
- export async function getDiff(repoPath, target = "HEAD") {
464
- const result = await run(["diff", target], repoPath)
465
- return {
466
- ok: result.ok,
467
- diff: result.ok ? result.stdout : undefined,
468
- error: result.ok ? undefined : result.stderr
469
- }
470
- }
471
-
472
- /**
473
- * 获取暂存区的 diff
474
- *
475
- * @param {string} repoPath - 仓库路径
476
- * @returns {Promise<{ok: boolean, diff?: string, error?: string}>}
477
- */
478
- export async function getStagedDiff(repoPath) {
479
- const result = await run(["diff", "--staged"], repoPath)
480
- return {
481
- ok: result.ok,
482
- diff: result.ok ? result.stdout : undefined,
483
- error: result.ok ? undefined : result.stderr
484
- }
485
- }
486
-
487
- // ============================================================================
488
- // Conflict Detection Helpers
489
- // ============================================================================
490
-
491
- /** Check if an error is a merge conflict */
492
- export function isConflictError(error) {
493
- const msg = String(error?.message || error || "")
494
- return msg.includes("CONFLICT") || msg.includes("Merge conflict") || msg.includes("merge conflict")
495
- }
496
-
497
- /** Get list of files with merge conflicts */
498
- export async function getConflictFiles(cwd = process.cwd()) {
499
- const result = await run(["diff", "--name-only", "--diff-filter=U"], cwd)
500
- if (!result.ok) return []
501
- return result.stdout.trim().split("\n").filter(Boolean)
502
- }
503
-
504
- /** Abort an in-progress merge */
505
- export async function mergeAbort(cwd = process.cwd()) {
506
- const result = await run(["merge", "--abort"], cwd)
507
- return { ok: result.ok, message: result.ok ? "merge aborted" : result.stderr }
508
- }
509
-
510
- // ============================================================================
511
- // 内部工具函数
512
- // ============================================================================
513
-
514
- /** 生成幽灵提交ID */
515
- function generateGhostCommitId() {
516
- const timestamp = Date.now().toString(36)
517
- const random = Math.random().toString(36).substring(2, 8)
518
- return `gc_${timestamp}_${random}`
519
- }
1
+ import { spawn } from "node:child_process"
2
+ import { mkdtemp, writeFile, unlink, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+
6
+ const GIT_TIMEOUT_MS = 30000
7
+
8
+ function run(args, cwd = process.cwd(), timeoutMs = GIT_TIMEOUT_MS, env = {}) {
9
+ return new Promise((resolve) => {
10
+ let stdout = ""
11
+ let stderr = ""
12
+ let done = false
13
+
14
+ const child = spawn("git", args, {
15
+ cwd,
16
+ windowsHide: true,
17
+ stdio: ["ignore", "pipe", "pipe"],
18
+ env: { ...process.env, ...env }
19
+ })
20
+
21
+ const timer = setTimeout(() => {
22
+ done = true
23
+ child.kill()
24
+ resolve({ ok: false, stdout, stderr: "git command timed out", code: null })
25
+ }, timeoutMs)
26
+
27
+ child.stdout.on("data", (buf) => { stdout += String(buf) })
28
+ child.stderr.on("data", (buf) => { stderr += String(buf) })
29
+
30
+ child.on("error", (error) => {
31
+ if (done) return
32
+ done = true
33
+ clearTimeout(timer)
34
+ resolve({ ok: false, stdout, stderr: error.message, code: null })
35
+ })
36
+
37
+ child.on("close", (code) => {
38
+ if (done) return
39
+ done = true
40
+ clearTimeout(timer)
41
+ resolve({ ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code })
42
+ })
43
+ })
44
+ }
45
+
46
+ /** Check if cwd is inside a git repo */
47
+ export async function isGitRepo(cwd = process.cwd()) {
48
+ const result = await run(["rev-parse", "--is-inside-work-tree"], cwd)
49
+ return result.ok && result.stdout.trim() === "true"
50
+ }
51
+
52
+ /** Get current branch name */
53
+ export async function currentBranch(cwd = process.cwd()) {
54
+ const result = await run(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
55
+ return result.ok ? result.stdout.trim() : null
56
+ }
57
+
58
+ /** Check if working tree is clean */
59
+ export async function isClean(cwd = process.cwd()) {
60
+ const result = await run(["status", "--porcelain"], cwd)
61
+ return result.ok && !result.stdout.trim()
62
+ }
63
+
64
+ /** Create and checkout a new branch */
65
+ export async function createBranch(name, cwd = process.cwd()) {
66
+ const result = await run(["checkout", "-b", name], cwd)
67
+ return { ok: result.ok, message: result.ok ? `created branch: ${name}` : result.stderr }
68
+ }
69
+
70
+ /** Checkout an existing branch */
71
+ export async function checkoutBranch(name, cwd = process.cwd()) {
72
+ const result = await run(["checkout", name], cwd)
73
+ return { ok: result.ok, message: result.ok ? `switched to: ${name}` : result.stderr }
74
+ }
75
+
76
+ /** Stage all changes and commit */
77
+ export async function commitAll(message, cwd = process.cwd()) {
78
+ const add = await run(["add", "-A"], cwd)
79
+ if (!add.ok) return { ok: false, message: `git add failed: ${add.stderr}` }
80
+ const commit = await run(["commit", "-m", message, "--allow-empty"], cwd)
81
+ if (!commit.ok) {
82
+ // Nothing to commit is not an error
83
+ if (commit.stderr.includes("nothing to commit")) {
84
+ return { ok: true, message: "nothing to commit", empty: true }
85
+ }
86
+ return { ok: false, message: `git commit failed: ${commit.stderr}` }
87
+ }
88
+ return { ok: true, message: commit.stdout.split("\n")[0] || "committed" }
89
+ }
90
+
91
+ /** Merge a branch into current branch */
92
+ export async function mergeBranch(source, cwd = process.cwd()) {
93
+ const result = await run(["merge", source, "--no-ff", "-m", `Merge branch '${source}'`], cwd)
94
+ return { ok: result.ok, message: result.ok ? `merged ${source}` : result.stderr }
95
+ }
96
+
97
+ /** Delete a branch */
98
+ export async function deleteBranch(name, cwd = process.cwd()) {
99
+ const result = await run(["branch", "-d", name], cwd)
100
+ return { ok: result.ok, message: result.ok ? `deleted branch: ${name}` : result.stderr }
101
+ }
102
+
103
+ /** Get short log of recent commits */
104
+ export async function recentCommits(count = 5, cwd = process.cwd()) {
105
+ const result = await run(["log", `--oneline`, `-${count}`], cwd)
106
+ return result.ok ? result.stdout.trim().split("\n").filter(Boolean) : []
107
+ }
108
+
109
+ /** Get diff stat summary */
110
+ export async function diffStat(cwd = process.cwd()) {
111
+ const result = await run(["diff", "--stat", "HEAD"], cwd)
112
+ return result.ok ? result.stdout.trim() : ""
113
+ }
114
+
115
+ /** Create a detached git worktree rooted at HEAD for isolated local execution */
116
+ export async function createDetachedWorktree(cwd = process.cwd(), label = "task") {
117
+ if (!(await isGitRepo(cwd))) {
118
+ return { ok: false, error: "not a git repository" }
119
+ }
120
+
121
+ const prefix = `kkcode-worktree-${String(label || "task").replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 24)}-`
122
+ const worktreePath = await mkdtemp(path.join(tmpdir(), prefix))
123
+ const addResult = await run(["worktree", "add", "--detach", worktreePath, "HEAD"], cwd, GIT_TIMEOUT_MS)
124
+ if (!addResult.ok) {
125
+ await rm(worktreePath, { recursive: true, force: true }).catch(() => {})
126
+ return { ok: false, error: addResult.stderr || "git worktree add failed" }
127
+ }
128
+ return { ok: true, path: worktreePath }
129
+ }
130
+
131
+ /** Remove an existing git worktree */
132
+ export async function removeWorktree(worktreePath, cwd = process.cwd()) {
133
+ const result = await run(["worktree", "remove", "--force", worktreePath], cwd, GIT_TIMEOUT_MS)
134
+ if (!result.ok) {
135
+ await rm(worktreePath, { recursive: true, force: true }).catch(() => {})
136
+ }
137
+ return { ok: result.ok, message: result.ok ? `removed worktree: ${worktreePath}` : result.stderr || "git worktree remove failed" }
138
+ }
139
+
140
+ /** Stash current changes */
141
+ export async function stash(message = "auto-stash", cwd = process.cwd()) {
142
+ const result = await run(["stash", "push", "-m", message], cwd)
143
+ return { ok: result.ok, message: result.ok ? result.stdout.trim() : result.stderr }
144
+ }
145
+
146
+ /** Pop stash */
147
+ export async function stashPop(cwd = process.cwd()) {
148
+ const result = await run(["stash", "pop"], cwd)
149
+ return { ok: result.ok, message: result.ok ? result.stdout.trim() : result.stderr }
150
+ }
151
+
152
+ /** Generate a branch name from session/objective */
153
+ export function generateBranchName(sessionId, objective = "") {
154
+ const prefix = "kkcode"
155
+ const shortId = String(sessionId || "").slice(0, 8)
156
+ const slug = String(objective || "")
157
+ .toLowerCase()
158
+ .replace(/[^a-z0-9\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]+/g, "-")
159
+ .replace(/^-|-$/g, "")
160
+ .slice(0, 40)
161
+ return `${prefix}/${shortId}${slug ? "-" + slug : ""}`
162
+ }
163
+
164
+ // ============================================================================
165
+ // Ghost Commit (幽灵提交) - AI Agent Git 自动化核心功能
166
+ // ============================================================================
167
+
168
+ /**
169
+ * Ghost Commit 元数据结构
170
+ * @typedef {Object} GhostCommitInfo
171
+ * @property {string} id - 幽灵提交ID (UUID)
172
+ * @property {string} commitHash - Git 提交对象哈希
173
+ * @property {string} repoPath - 仓库绝对路径
174
+ * @property {string} parentHash - 父提交哈希
175
+ * @property {string} message - 提交信息
176
+ * @property {number} createdAt - 创建时间戳
177
+ * @property {string[]} files - 包含的文件列表
178
+ */
179
+
180
+ /**
181
+ * 创建幽灵提交 (Ghost Commit)
182
+ * 使用临时索引创建不引用在任何分支上的提交对象
183
+ *
184
+ * @param {string} repoPath - 仓库路径
185
+ * @param {string} message - 提交信息
186
+ * @param {string[]} [paths=[]] - 要包含的文件路径(相对于repoPath),空数组表示所有更改
187
+ * @returns {Promise<{ok: boolean, ghostCommit?: GhostCommitInfo, error?: string}>}
188
+ */
189
+ export async function createGhostCommit(repoPath, message = "kkcode snapshot", paths = []) {
190
+ // 检查是否是 Git 仓库
191
+ if (!(await isGitRepo(repoPath))) {
192
+ return { ok: false, error: "not a git repository" }
193
+ }
194
+
195
+ // 获取当前 HEAD
196
+ const headResult = await run(["rev-parse", "HEAD"], repoPath)
197
+ if (!headResult.ok) {
198
+ return { ok: false, error: `failed to get HEAD: ${headResult.stderr}` }
199
+ }
200
+ const parentHash = headResult.stdout.trim()
201
+
202
+ // 创建临时目录和临时索引文件
203
+ let tmpDir = null
204
+ let indexPath = null
205
+
206
+ try {
207
+ tmpDir = await mkdtemp(path.join(tmpdir(), "kkcode-git-"))
208
+ indexPath = path.join(tmpDir, "index")
209
+
210
+ // 1. 读取当前 HEAD 到临时索引
211
+ const readTreeResult = await run(
212
+ ["read-tree", "HEAD"],
213
+ repoPath,
214
+ GIT_TIMEOUT_MS,
215
+ { GIT_INDEX_FILE: indexPath }
216
+ )
217
+ if (!readTreeResult.ok) {
218
+ return { ok: false, error: `read-tree failed: ${readTreeResult.stderr}` }
219
+ }
220
+
221
+ // 2. 添加更改到临时索引
222
+ const addArgs = paths.length > 0
223
+ ? ["add", "--", ...paths]
224
+ : ["add", "-A"]
225
+ const addResult = await run(
226
+ addArgs,
227
+ repoPath,
228
+ GIT_TIMEOUT_MS,
229
+ { GIT_INDEX_FILE: indexPath }
230
+ )
231
+ if (!addResult.ok) {
232
+ return { ok: false, error: `git add failed: ${addResult.stderr}` }
233
+ }
234
+
235
+ // 3. 写入树对象
236
+ const writeTreeResult = await run(
237
+ ["write-tree"],
238
+ repoPath,
239
+ GIT_TIMEOUT_MS,
240
+ { GIT_INDEX_FILE: indexPath }
241
+ )
242
+ if (!writeTreeResult.ok) {
243
+ return { ok: false, error: `write-tree failed: ${writeTreeResult.stderr}` }
244
+ }
245
+ const treeHash = writeTreeResult.stdout.trim()
246
+
247
+ // 4. 创建提交对象 (幽灵提交)
248
+ const commitTreeResult = await run(
249
+ ["commit-tree", treeHash, "-p", parentHash, "-m", message],
250
+ repoPath
251
+ )
252
+ if (!commitTreeResult.ok) {
253
+ return { ok: false, error: `commit-tree failed: ${commitTreeResult.stderr}` }
254
+ }
255
+ const commitHash = commitTreeResult.stdout.trim()
256
+
257
+ // 5. 获取包含的文件列表
258
+ const diffResult = await run(
259
+ ["diff-tree", "--no-commit-id", "--name-only", "-r", commitHash],
260
+ repoPath
261
+ )
262
+ const files = diffResult.ok
263
+ ? diffResult.stdout.trim().split("\n").filter(Boolean)
264
+ : []
265
+
266
+ return {
267
+ ok: true,
268
+ ghostCommit: {
269
+ id: generateGhostCommitId(),
270
+ commitHash,
271
+ repoPath: path.resolve(repoPath),
272
+ parentHash,
273
+ message,
274
+ createdAt: Date.now(),
275
+ files
276
+ }
277
+ }
278
+ } catch (error) {
279
+ return { ok: false, error: error.message }
280
+ } finally {
281
+ // 清理临时目录
282
+ if (tmpDir) {
283
+ try {
284
+ await rm(tmpDir, { recursive: true, force: true })
285
+ } catch { /* ignore cleanup errors */ }
286
+ }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 恢复到幽灵提交状态
292
+ * 使用 git restore 将工作区恢复到幽灵提交的状态
293
+ *
294
+ * @param {string} repoPath - 仓库路径
295
+ * @param {string} commitHash - 幽灵提交的 commit hash
296
+ * @param {boolean} [restoreIndex=false] - 是否也恢复暂存区
297
+ * @returns {Promise<{ok: boolean, message?: string, error?: string}>}
298
+ */
299
+ export async function restoreGhostCommit(repoPath, commitHash, restoreIndex = false) {
300
+ // 验证提交对象存在
301
+ const catFileResult = await run(["cat-file", "-t", commitHash], repoPath)
302
+ if (!catFileResult.ok || catFileResult.stdout.trim() !== "commit") {
303
+ return { ok: false, error: `invalid commit hash: ${commitHash}` }
304
+ }
305
+
306
+ // 恢复工作区
307
+ const restoreArgs = ["restore", "--source", commitHash, "."]
308
+ const restoreResult = await run(restoreArgs, repoPath)
309
+ if (!restoreResult.ok) {
310
+ return { ok: false, error: `restore failed: ${restoreResult.stderr}` }
311
+ }
312
+
313
+ // 如果需要,也恢复暂存区
314
+ if (restoreIndex) {
315
+ const readTreeResult = await run(["read-tree", commitHash], repoPath)
316
+ if (!readTreeResult.ok) {
317
+ return { ok: false, error: `restore index failed: ${readTreeResult.stderr}` }
318
+ }
319
+ }
320
+
321
+ return { ok: true, message: `restored to ${commitHash.slice(0, 8)}` }
322
+ }
323
+
324
+ /**
325
+ * 应用 Patch (AI 生成的 diff)
326
+ * 支持 git apply --3way 进行三方合并
327
+ *
328
+ * @param {string} repoPath - 仓库路径
329
+ * @param {string} diff - 统一格式的 diff 文本
330
+ * @param {Object} options - 选项
331
+ * @param {boolean} [options.threeway=true] - 使用三方合并
332
+ * @param {boolean} [options.check=false] - 仅检查,不实际应用
333
+ * @param {boolean} [options.whitespace="nowarn"] - 空白字符处理
334
+ * @returns {Promise<{ok: boolean, applied?: string[], skipped?: string[], conflicts?: string[], error?: string}>}
335
+ */
336
+ export async function applyPatch(repoPath, diff, options = {}) {
337
+ const {
338
+ threeway = true,
339
+ check = false,
340
+ whitespace = "nowarn"
341
+ } = options
342
+
343
+ // 创建临时 patch 文件
344
+ let patchPath = null
345
+ try {
346
+ const tmpDir = await mkdtemp(path.join(tmpdir(), "kkcode-patch-"))
347
+ patchPath = path.join(tmpDir, "changes.patch")
348
+ await writeFile(patchPath, diff, "utf8")
349
+
350
+ // 构建 git apply 参数
351
+ const applyArgs = ["apply"]
352
+ if (threeway) applyArgs.push("--3way")
353
+ if (check) applyArgs.push("--check")
354
+ if (whitespace) applyArgs.push(`--whitespace=${whitespace}`)
355
+ if (!check) applyArgs.push("-v") // verbose for parsing results
356
+ applyArgs.push(patchPath)
357
+
358
+ const result = await run(applyArgs, repoPath)
359
+
360
+ // 解析结果
361
+ if (!result.ok) {
362
+ // 解析错误信息,提取冲突文件
363
+ const conflictMatch = result.stderr.match(/error: patch failed: (.+)/g)
364
+ const conflicts = conflictMatch
365
+ ? conflictMatch.map(m => m.replace(/error: patch failed: /, "").split(":")[0])
366
+ : []
367
+
368
+ return {
369
+ ok: false,
370
+ error: result.stderr,
371
+ conflicts
372
+ }
373
+ }
374
+
375
+ // 解析成功应用的文件
376
+ const appliedMatch = result.stdout.match(/Applied patch to (.+)/g)
377
+ const applied = appliedMatch
378
+ ? appliedMatch.map(m => m.replace(/Applied patch to /, "").trim())
379
+ : []
380
+
381
+ return {
382
+ ok: true,
383
+ applied,
384
+ skipped: [],
385
+ conflicts: []
386
+ }
387
+ } catch (error) {
388
+ return { ok: false, error: error.message }
389
+ } finally {
390
+ // 清理临时文件
391
+ if (patchPath) {
392
+ try {
393
+ const tmpDir = path.dirname(patchPath)
394
+ await unlink(patchPath)
395
+ await rmdir(tmpDir)
396
+ } catch { /* ignore cleanup errors */ }
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * 预检 Patch - 检查 patch 是否可以应用,不实际修改文件
403
+ *
404
+ * @param {string} repoPath - 仓库路径
405
+ * @param {string} diff - 统一格式的 diff 文本
406
+ * @returns {Promise<{applicable: boolean, conflicts?: string[], error?: string}>}
407
+ */
408
+ export async function preflightPatch(repoPath, diff) {
409
+ const result = await applyPatch(repoPath, diff, { check: true })
410
+ return {
411
+ applicable: result.ok,
412
+ conflicts: result.conflicts,
413
+ error: result.error
414
+ }
415
+ }
416
+
417
+ /**
418
+ * 获取 Git 仓库信息
419
+ * 收集当前仓库的上下文信息供 AI 使用
420
+ *
421
+ * @param {string} repoPath - 仓库路径
422
+ * @returns {Promise<{ok: boolean, info?: Object, error?: string}>}
423
+ */
424
+ export async function getGitInfo(repoPath) {
425
+ if (!(await isGitRepo(repoPath))) {
426
+ return { ok: false, error: "not a git repository" }
427
+ }
428
+
429
+ try {
430
+ // 并行获取各种信息
431
+ const [
432
+ branchResult,
433
+ commitResult,
434
+ remoteResult,
435
+ statusResult,
436
+ statusPorcelain
437
+ ] = await Promise.all([
438
+ run(["rev-parse", "--abbrev-ref", "HEAD"], repoPath),
439
+ run(["rev-parse", "HEAD"], repoPath),
440
+ run(["remote", "-v"], repoPath),
441
+ run(["status", "--short"], repoPath),
442
+ run(["status", "--porcelain"], repoPath)
443
+ ])
444
+
445
+ // 解析远程仓库信息
446
+ const remotes = remoteResult.ok
447
+ ? remoteResult.stdout.split("\n")
448
+ .filter(line => line.includes("(fetch)"))
449
+ .map(line => {
450
+ const parts = line.split(/\s+/)
451
+ return { name: parts[0], url: parts[1] }
452
+ })
453
+ : []
454
+
455
+ // 解析状态
456
+ const hasUncommittedChanges = statusPorcelain.ok && statusPorcelain.stdout.trim() !== ""
457
+ const changedFiles = statusPorcelain.ok
458
+ ? statusPorcelain.stdout.split("\n").filter(Boolean).map(line => ({
459
+ status: line.slice(0, 2),
460
+ path: line.slice(3)
461
+ }))
462
+ : []
463
+
464
+ return {
465
+ ok: true,
466
+ info: {
467
+ isGitRepo: true,
468
+ currentBranch: branchResult.ok ? branchResult.stdout.trim() : null,
469
+ currentCommit: commitResult.ok ? commitResult.stdout.trim() : null,
470
+ remotes,
471
+ hasUncommittedChanges,
472
+ changedFiles,
473
+ statusSummary: statusResult.ok ? statusResult.stdout : ""
474
+ }
475
+ }
476
+ } catch (error) {
477
+ return { ok: false, error: error.message }
478
+ }
479
+ }
480
+
481
+ /**
482
+ * 获取当前工作目录与指定提交的 diff
483
+ *
484
+ * @param {string} repoPath - 仓库路径
485
+ * @param {string} [target="HEAD"] - 目标提交
486
+ * @returns {Promise<{ok: boolean, diff?: string, error?: string}>}
487
+ */
488
+ export async function getDiff(repoPath, target = "HEAD") {
489
+ const result = await run(["diff", target], repoPath)
490
+ return {
491
+ ok: result.ok,
492
+ diff: result.ok ? result.stdout : undefined,
493
+ error: result.ok ? undefined : result.stderr
494
+ }
495
+ }
496
+
497
+ /**
498
+ * 获取暂存区的 diff
499
+ *
500
+ * @param {string} repoPath - 仓库路径
501
+ * @returns {Promise<{ok: boolean, diff?: string, error?: string}>}
502
+ */
503
+ export async function getStagedDiff(repoPath) {
504
+ const result = await run(["diff", "--staged"], repoPath)
505
+ return {
506
+ ok: result.ok,
507
+ diff: result.ok ? result.stdout : undefined,
508
+ error: result.ok ? undefined : result.stderr
509
+ }
510
+ }
511
+
512
+ // ============================================================================
513
+ // Conflict Detection Helpers
514
+ // ============================================================================
515
+
516
+ /** Check if an error is a merge conflict */
517
+ export function isConflictError(error) {
518
+ const msg = String(error?.message || error || "")
519
+ return msg.includes("CONFLICT") || msg.includes("Merge conflict") || msg.includes("merge conflict")
520
+ }
521
+
522
+ /** Get list of files with merge conflicts */
523
+ export async function getConflictFiles(cwd = process.cwd()) {
524
+ const result = await run(["diff", "--name-only", "--diff-filter=U"], cwd)
525
+ if (!result.ok) return []
526
+ return result.stdout.trim().split("\n").filter(Boolean)
527
+ }
528
+
529
+ /** Abort an in-progress merge */
530
+ export async function mergeAbort(cwd = process.cwd()) {
531
+ const result = await run(["merge", "--abort"], cwd)
532
+ return { ok: result.ok, message: result.ok ? "merge aborted" : result.stderr }
533
+ }
534
+
535
+ /** Get current HEAD commit hash (for rollback savepoints) */
536
+ export async function getHeadHash(cwd = process.cwd()) {
537
+ const result = await run(["rev-parse", "HEAD"], cwd)
538
+ return result.ok ? result.stdout.trim() : null
539
+ }
540
+
541
+ /** Hard reset to a specific commit (rollback) */
542
+ export async function resetTo(ref, cwd = process.cwd()) {
543
+ const result = await run(["reset", "--hard", ref], cwd)
544
+ return { ok: result.ok, message: result.ok ? `reset to ${ref}` : result.stderr }
545
+ }
546
+
547
+ /** Check if conflict markers remain in working tree */
548
+ export async function hasConflictMarkers(cwd = process.cwd()) {
549
+ const result = await run(["diff", "--check"], cwd)
550
+ return !result.ok
551
+ }
552
+
553
+ // ============================================================================
554
+ // 内部工具函数
555
+ // ============================================================================
556
+
557
+ /** 生成幽灵提交ID */
558
+ function generateGhostCommitId() {
559
+ const timestamp = Date.now().toString(36)
560
+ const random = Math.random().toString(36).substring(2, 8)
561
+ return `gc_${timestamp}_${random}`
562
+ }