@jojonax/codex-copilot 1.0.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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * codex-copilot run - 主编排循环
3
+ *
4
+ * 逐个任务执行: 开发 → PR → Review → 修复 → 合并 → 下一个
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
8
+ import { resolve } from 'path';
9
+ import { log, progressBar } from '../utils/logger.js';
10
+ import { git } from '../utils/git.js';
11
+ import { github } from '../utils/github.js';
12
+ import { ask, confirm, closePrompt } from '../utils/prompt.js';
13
+
14
+ function readJSON(path) {
15
+ return JSON.parse(readFileSync(path, 'utf-8'));
16
+ }
17
+
18
+ function writeJSON(path, data) {
19
+ writeFileSync(path, JSON.stringify(data, null, 2));
20
+ }
21
+
22
+ export async function run(projectDir) {
23
+ const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
24
+ const statePath = resolve(projectDir, '.codex-copilot/state.json');
25
+ const configPath = resolve(projectDir, '.codex-copilot/config.json');
26
+
27
+ const tasks = readJSON(tasksPath);
28
+ let state = readJSON(statePath);
29
+ const config = existsSync(configPath) ? readJSON(configPath) : {};
30
+
31
+ const baseBranch = config.base_branch || 'main';
32
+ const maxReviewRounds = config.max_review_rounds || 2;
33
+ const pollInterval = config.review_poll_interval || 60;
34
+ const waitTimeout = config.review_wait_timeout || 600;
35
+
36
+ log.title('🚀 开始自动化开发循环');
37
+ log.blank();
38
+ log.info(`项目: ${tasks.project}`);
39
+ log.info(`总任务数: ${tasks.total}`);
40
+ log.info(`已完成: ${state.current_task}`);
41
+ log.info(`基础分支: ${baseBranch}`);
42
+ log.blank();
43
+ progressBar(state.current_task, tasks.total, `${state.current_task}/${tasks.total} 任务完成`);
44
+ log.blank();
45
+
46
+ // 逐个任务执行
47
+ for (const task of tasks.tasks) {
48
+ if (task.id <= state.current_task) continue; // 跳过已完成的
49
+ if (task.status === 'completed' || task.status === 'skipped') continue;
50
+
51
+ log.blank();
52
+ log.title(`━━━ 任务 ${task.id}/${tasks.total}: ${task.title} ━━━`);
53
+ log.blank();
54
+
55
+ // 检查依赖是否已完成
56
+ if (task.depends_on && task.depends_on.length > 0) {
57
+ const unfinished = task.depends_on.filter(dep => {
58
+ const depTask = tasks.tasks.find(t => t.id === dep);
59
+ return depTask && depTask.status !== 'completed';
60
+ });
61
+ if (unfinished.length > 0) {
62
+ log.warn(`依赖任务未完成: ${unfinished.join(', ')},跳过`);
63
+ continue;
64
+ }
65
+ }
66
+
67
+ // ===== 阶段 1: 开发 =====
68
+ await developPhase(projectDir, task, baseBranch);
69
+
70
+ // ===== 阶段 2: 创建 PR =====
71
+ const prInfo = await prPhase(projectDir, task, baseBranch);
72
+
73
+ // ===== 阶段 3: Review 循环 =====
74
+ await reviewLoop(projectDir, task, prInfo, {
75
+ maxRounds: maxReviewRounds,
76
+ pollInterval,
77
+ waitTimeout,
78
+ });
79
+
80
+ // ===== 阶段 4: 合并 =====
81
+ await mergePhase(projectDir, task, prInfo);
82
+
83
+ // 更新任务状态
84
+ task.status = 'completed';
85
+ state.current_task = task.id;
86
+ state.current_pr = null;
87
+ state.review_round = 0;
88
+ writeJSON(tasksPath, tasks);
89
+ writeJSON(statePath, state);
90
+
91
+ log.blank();
92
+ progressBar(task.id, tasks.total, `${task.id}/${tasks.total} 任务完成`);
93
+ log.info(`✅ 任务 #${task.id} 完成!`);
94
+ }
95
+
96
+ log.blank();
97
+ log.title('🎉 所有任务已完成!');
98
+ log.blank();
99
+ closePrompt();
100
+ }
101
+
102
+ // ──────────────────────────────────────────────
103
+ // 阶段 1: 开发
104
+ // ──────────────────────────────────────────────
105
+ async function developPhase(projectDir, task, baseBranch) {
106
+ log.step('阶段 1/4: 开发');
107
+
108
+ // 切换到 feature 分支
109
+ git.checkoutBranch(projectDir, task.branch, baseBranch);
110
+ log.info(`已切换到分支: ${task.branch}`);
111
+
112
+ // 构建开发 Prompt
113
+ const devPrompt = buildDevPrompt(task);
114
+
115
+ // 检查是否有 CodeX CLI
116
+ let codexAvailable = false;
117
+ try {
118
+ const { execSync } = await import('child_process');
119
+ execSync('which codex', { stdio: 'pipe' });
120
+ codexAvailable = true;
121
+ } catch {}
122
+
123
+ if (codexAvailable) {
124
+ log.info('检测到 CodeX CLI,自动执行...');
125
+ const autoRun = await confirm('自动调用 CodeX 开发?');
126
+ if (autoRun) {
127
+ try {
128
+ const { execSync } = await import('child_process');
129
+ // 将 prompt 写入临时文件
130
+ const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
131
+ const { writeFileSync } = await import('fs');
132
+ writeFileSync(promptPath, devPrompt);
133
+ execSync(`codex -q --file .codex-copilot/_current_prompt.md`, {
134
+ cwd: projectDir,
135
+ stdio: 'inherit',
136
+ });
137
+ log.info('CodeX 开发完成');
138
+ return;
139
+ } catch (err) {
140
+ log.warn(`CodeX CLI 调用失败: ${err.message}`);
141
+ log.warn('回退到手动模式');
142
+ }
143
+ }
144
+ }
145
+
146
+ // 手动模式:显示 Prompt,等待用户确认
147
+ log.blank();
148
+ console.log(' ┌─── 请将以下内容粘贴到 CodeX 桌面版中执行 ───┐');
149
+ log.blank();
150
+ console.log(devPrompt.split('\n').map(l => ` │ ${l}`).join('\n'));
151
+ log.blank();
152
+ console.log(' └──────────────────────────────────────────────┘');
153
+ log.blank();
154
+
155
+ // 同时将 Prompt 保存到文件,方便复制
156
+ const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
157
+ const { writeFileSync: ws } = await import('fs');
158
+ ws(promptPath, devPrompt);
159
+ log.dim(`Prompt 已保存到 .codex-copilot/_current_prompt.md (可直接复制文件内容)`);
160
+ log.blank();
161
+
162
+ // 尝试复制到剪贴板
163
+ try {
164
+ const { execSync } = await import('child_process');
165
+ execSync(`echo '${devPrompt.replace(/'/g, "\\'")}' | pbcopy`, { stdio: 'pipe' });
166
+ log.info('📋 已复制到剪贴板!直接到 CodeX 中粘贴即可');
167
+ } catch {}
168
+
169
+ await ask('CodeX 开发完成后按 Enter 继续...');
170
+ }
171
+
172
+ // ──────────────────────────────────────────────
173
+ // 阶段 2: 创建 PR
174
+ // ──────────────────────────────────────────────
175
+ async function prPhase(projectDir, task, baseBranch) {
176
+ log.step('阶段 2/4: 提交 PR');
177
+
178
+ // 确保变更已提交
179
+ if (!git.isClean(projectDir)) {
180
+ log.info('检测到未提交的变更,自动提交...');
181
+ git.commitAll(projectDir, `feat(task-${task.id}): ${task.title}`);
182
+ }
183
+
184
+ // 推送
185
+ git.pushBranch(projectDir, task.branch);
186
+ log.info('代码已推送');
187
+
188
+ // 创建 PR
189
+ const prBody = `## 任务 #${task.id}: ${task.title}\n\n${task.description}\n\n### 验收标准\n${task.acceptance.map(a => `- [ ] ${a}`).join('\n')}\n\n---\n*由 Codex-Copilot 自动创建*`;
190
+
191
+ try {
192
+ const prInfo = github.createPR(projectDir, {
193
+ title: `feat(task-${task.id}): ${task.title}`,
194
+ body: prBody,
195
+ base: baseBranch,
196
+ head: task.branch,
197
+ });
198
+ log.info(`PR 已创建: ${prInfo.url}`);
199
+ return prInfo;
200
+ } catch (err) {
201
+ // PR 可能已存在
202
+ log.warn(`创建 PR 异常: ${err.message}`);
203
+ const prNumber = await ask('请输入已存在的 PR 编号:');
204
+ return { number: parseInt(prNumber), url: '' };
205
+ }
206
+ }
207
+
208
+ // ──────────────────────────────────────────────
209
+ // 阶段 3: Review 循环
210
+ // ──────────────────────────────────────────────
211
+ async function reviewLoop(projectDir, task, prInfo, { maxRounds, pollInterval, waitTimeout }) {
212
+ log.step('阶段 3/4: 等待 Review');
213
+
214
+ for (let round = 1; round <= maxRounds; round++) {
215
+ // 等待 Review
216
+ log.info(`等待 Review 意见... (超时: ${waitTimeout}s)`);
217
+ const gotReview = await waitForReview(projectDir, prInfo.number, pollInterval, waitTimeout);
218
+
219
+ if (!gotReview) {
220
+ log.warn('等待 Review 超时');
221
+ const action = await ask('输入 skip (跳过Review) 或 wait (继续等待):');
222
+ if (action === 'wait') {
223
+ round--;
224
+ continue;
225
+ }
226
+ break;
227
+ }
228
+
229
+ // 检查 Review 状态
230
+ const state = github.getLatestReviewState(projectDir, prInfo.number);
231
+ log.info(`Review 状态: ${state}`);
232
+
233
+ if (state === 'APPROVED') {
234
+ log.info('✅ Review 已通过!');
235
+ return;
236
+ }
237
+
238
+ // 收集 Review 反馈
239
+ const feedback = github.collectReviewFeedback(projectDir, prInfo.number);
240
+ if (!feedback) {
241
+ log.info('未发现具体修改意见,继续');
242
+ return;
243
+ }
244
+
245
+ log.blank();
246
+ log.warn(`收到 Review 意见 (第 ${round}/${maxRounds} 轮)`);
247
+
248
+ if (round >= maxRounds) {
249
+ log.warn(`已达最大修复轮次 (${maxRounds})`);
250
+ const choice = await ask('输入 merge (强制合并) / fix (再修一轮) / skip (跳过):');
251
+ if (choice === 'fix') {
252
+ maxRounds++;
253
+ } else if (choice === 'skip') {
254
+ return;
255
+ } else {
256
+ return; // merge
257
+ }
258
+ }
259
+
260
+ // 让 CodeX 修复
261
+ await fixPhase(projectDir, task, feedback, round);
262
+
263
+ // 推送修复
264
+ if (!git.isClean(projectDir)) {
265
+ git.commitAll(projectDir, `fix(task-${task.id}): address review comments (round ${round})`);
266
+ }
267
+ git.pushBranch(projectDir, task.branch);
268
+ log.info('修复已推送,等待新一轮 Review...');
269
+
270
+ // 稍等一下让 Review bot 反应
271
+ await sleep(10000);
272
+ }
273
+ }
274
+
275
+ async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
276
+ let elapsed = 0;
277
+ const startReviewCount = github.getReviews(projectDir, prNumber).length;
278
+
279
+ while (elapsed < timeout) {
280
+ await sleep(pollInterval * 1000);
281
+ elapsed += pollInterval;
282
+
283
+ const currentReviews = github.getReviews(projectDir, prNumber);
284
+ const currentComments = github.getIssueComments(projectDir, prNumber);
285
+
286
+ // 检查是否有新的 Review 或 Bot 评论
287
+ const hasNewReview = currentReviews.length > startReviewCount;
288
+ const hasBotComment = currentComments.some(c =>
289
+ (c.user?.type === 'Bot' || c.user?.login?.includes('bot')) &&
290
+ new Date(c.created_at) > new Date(Date.now() - elapsed * 1000)
291
+ );
292
+
293
+ if (hasNewReview || hasBotComment) {
294
+ return true;
295
+ }
296
+
297
+ process.stdout.write('.');
298
+ }
299
+ console.log('');
300
+ return false;
301
+ }
302
+
303
+ // ──────────────────────────────────────────────
304
+ // 修复阶段
305
+ // ──────────────────────────────────────────────
306
+ async function fixPhase(projectDir, task, feedback, round) {
307
+ log.step(`修复 Review 意见 (第 ${round} 轮)`);
308
+
309
+ const fixPrompt = `以下是 PR Review 的意见,请逐一修复:
310
+
311
+ ## Review 意见
312
+ ${feedback}
313
+
314
+ ## 要求
315
+ 1. 逐条修复上述问题
316
+ 2. 建议性意见可以不修复,但在 commit message 中说明原因
317
+ 3. 修复后确保不引入新问题
318
+ 4. 完成后执行: git add -A && git commit -m "fix(task-${task.id}): address review round ${round}"
319
+ `;
320
+
321
+ // 保存到文件并提示用户
322
+ const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
323
+ const { writeFileSync } = await import('fs');
324
+ writeFileSync(promptPath, fixPrompt);
325
+
326
+ // 尝试 CodeX CLI
327
+ let codexAvailable = false;
328
+ try {
329
+ const { execSync } = await import('child_process');
330
+ execSync('which codex', { stdio: 'pipe' });
331
+ codexAvailable = true;
332
+ } catch {}
333
+
334
+ if (codexAvailable) {
335
+ const autoFix = await confirm('自动调用 CodeX 修复?');
336
+ if (autoFix) {
337
+ try {
338
+ const { execSync } = await import('child_process');
339
+ execSync(`codex -q --file .codex-copilot/_current_prompt.md`, {
340
+ cwd: projectDir,
341
+ stdio: 'inherit',
342
+ });
343
+ log.info('CodeX 修复完成');
344
+ return;
345
+ } catch {
346
+ log.warn('CodeX CLI 调用失败,回退到手动模式');
347
+ }
348
+ }
349
+ }
350
+
351
+ // 手动模式
352
+ log.blank();
353
+ log.dim(`Review 修复 Prompt 已保存到 .codex-copilot/_current_prompt.md`);
354
+ log.dim('请将文件内容粘贴到 CodeX 执行');
355
+
356
+ try {
357
+ const { execSync } = await import('child_process');
358
+ execSync(`cat .codex-copilot/_current_prompt.md | pbcopy`, { cwd: projectDir, stdio: 'pipe' });
359
+ log.info('📋 已复制到剪贴板');
360
+ } catch {}
361
+
362
+ await ask('CodeX 修复完成后按 Enter 继续...');
363
+ }
364
+
365
+ // ──────────────────────────────────────────────
366
+ // 阶段 4: 合并
367
+ // ──────────────────────────────────────────────
368
+ async function mergePhase(projectDir, task, prInfo) {
369
+ log.step('阶段 4/4: 合并 PR');
370
+
371
+ const doMerge = await confirm(`合并 PR #${prInfo.number}?`);
372
+ if (!doMerge) {
373
+ log.warn('用户跳过合并');
374
+ return;
375
+ }
376
+
377
+ try {
378
+ github.mergePR(projectDir, prInfo.number);
379
+ log.info(`PR #${prInfo.number} 已合并 ✅`);
380
+ } catch (err) {
381
+ log.error(`合并失败: ${err.message}`);
382
+ log.warn('请手动合并后按 Enter 继续');
383
+ await ask('继续...');
384
+ }
385
+
386
+ // 切回主分支
387
+ const configPath = resolve(projectDir, '.codex-copilot/config.json');
388
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
389
+ git.checkoutMain(projectDir, config.base_branch || 'main');
390
+ }
391
+
392
+ function buildDevPrompt(task) {
393
+ return `请完成以下开发任务:
394
+
395
+ ## 任务 #${task.id}: ${task.title}
396
+
397
+ ${task.description}
398
+
399
+ ## 验收标准
400
+ ${task.acceptance.map(a => `- ${a}`).join('\n')}
401
+
402
+ ## 要求
403
+ 1. 严格按照项目现有的代码规范和技术栈
404
+ 2. 完成后确保代码可以正常编译/运行
405
+ 3. 完成后执行:
406
+ git add -A
407
+ git commit -m "feat(task-${task.id}): ${task.title}"
408
+ `;
409
+ }
410
+
411
+ function sleep(ms) {
412
+ return new Promise(resolve => setTimeout(resolve, ms));
413
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * codex-copilot status - 显示当前进度
3
+ */
4
+
5
+ import { readFileSync } from 'fs';
6
+ import { resolve } from 'path';
7
+ import { log, progressBar } from '../utils/logger.js';
8
+
9
+ export async function status(projectDir) {
10
+ const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
11
+ const statePath = resolve(projectDir, '.codex-copilot/state.json');
12
+
13
+ const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
14
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'));
15
+
16
+ log.title(`📊 项目: ${tasks.project}`);
17
+ log.blank();
18
+
19
+ // 进度条
20
+ const completed = tasks.tasks.filter(t => t.status === 'completed').length;
21
+ const inProgress = tasks.tasks.filter(t => t.status === 'in_progress' || t.status === 'developed').length;
22
+ const pending = tasks.tasks.filter(t => t.status === 'pending').length;
23
+ const skipped = tasks.tasks.filter(t => t.status === 'skipped').length;
24
+
25
+ progressBar(completed, tasks.total, `${completed}/${tasks.total} 完成`);
26
+ log.blank();
27
+
28
+ // 统计
29
+ log.info(`✅ 已完成: ${completed}`);
30
+ if (inProgress > 0) log.info(`🔄 进行中: ${inProgress}`);
31
+ log.info(`⏳ 待开发: ${pending}`);
32
+ if (skipped > 0) log.warn(`⏭ 已跳过: ${skipped}`);
33
+ log.blank();
34
+
35
+ // 当前状态
36
+ if (state.current_pr) {
37
+ log.info(`当前 PR: #${state.current_pr} (Review 第 ${state.review_round} 轮)`);
38
+ }
39
+ log.blank();
40
+
41
+ // 任务列表
42
+ log.title('任务列表:');
43
+ log.blank();
44
+ for (const task of tasks.tasks) {
45
+ const icon = task.status === 'completed' ? '✅' :
46
+ task.status === 'in_progress' ? '🔄' :
47
+ task.status === 'developed' ? '📦' :
48
+ task.status === 'skipped' ? '⏭ ' : '⬜';
49
+ console.log(` ${icon} #${task.id} ${task.title} [${task.branch}]`);
50
+ }
51
+ log.blank();
52
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * PRD 自动检测模块
3
+ * 在项目目录中自动查找 PRD 文档
4
+ */
5
+
6
+ import { readdirSync, readFileSync, statSync } from 'fs';
7
+ import { resolve, basename, extname } from 'path';
8
+ import { log } from './logger.js';
9
+
10
+ // PRD 文件名匹配模式(优先级由高到低)
11
+ const PRD_PATTERNS = [
12
+ /prd/i,
13
+ /product.?requirement/i,
14
+ /product.?design/i,
15
+ /需求文档/,
16
+ /产品需求/,
17
+ /requirement/i,
18
+ /spec/i,
19
+ ];
20
+
21
+ // 搜索目录(按优先级)
22
+ const SEARCH_DIRS = [
23
+ '.', // 项目根目录
24
+ 'docs',
25
+ 'doc',
26
+ 'PRD',
27
+ 'prd',
28
+ '.docs',
29
+ 'documentation',
30
+ '文档',
31
+ ];
32
+
33
+ // 忽略目录
34
+ const IGNORE_DIRS = new Set([
35
+ 'node_modules', '.git', '.next', 'dist', 'build', 'vendor',
36
+ '.codex-copilot', '.vscode', '.idea', '__pycache__', 'coverage',
37
+ ]);
38
+
39
+ /**
40
+ * 自动检测当前项目中的 PRD 文件
41
+ * @param {string} projectDir - 项目根目录
42
+ * @returns {Array<{path: string, score: number, name: string}>} 候选 PRD 文件列表(按匹配度排序)
43
+ */
44
+ export function detectPRD(projectDir) {
45
+ const candidates = [];
46
+
47
+ for (const dir of SEARCH_DIRS) {
48
+ const searchPath = resolve(projectDir, dir);
49
+ try {
50
+ if (!statSync(searchPath).isDirectory()) continue;
51
+ } catch {
52
+ continue;
53
+ }
54
+
55
+ scanDir(searchPath, projectDir, candidates, 0);
56
+ }
57
+
58
+ // 按匹配度排序
59
+ candidates.sort((a, b) => b.score - a.score);
60
+
61
+ return candidates;
62
+ }
63
+
64
+ function scanDir(dir, projectDir, candidates, depth) {
65
+ if (depth > 3) return; // 最多搜索 3 层
66
+
67
+ let entries;
68
+ try {
69
+ entries = readdirSync(dir, { withFileTypes: true });
70
+ } catch {
71
+ return;
72
+ }
73
+
74
+ for (const entry of entries) {
75
+ const fullPath = resolve(dir, entry.name);
76
+
77
+ if (entry.isDirectory()) {
78
+ if (!IGNORE_DIRS.has(entry.name)) {
79
+ scanDir(fullPath, projectDir, candidates, depth + 1);
80
+ }
81
+ continue;
82
+ }
83
+
84
+ // 只搜索 markdown 文件
85
+ if (extname(entry.name).toLowerCase() !== '.md') continue;
86
+
87
+ // 计算匹配分数
88
+ const score = scorePRDMatch(entry.name, fullPath);
89
+ if (score > 0) {
90
+ candidates.push({
91
+ path: fullPath,
92
+ relativePath: fullPath.replace(projectDir + '/', ''),
93
+ name: entry.name,
94
+ score,
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ function scorePRDMatch(filename, fullPath) {
101
+ let score = 0;
102
+ const lower = filename.toLowerCase();
103
+
104
+ // 文件名匹配
105
+ for (let i = 0; i < PRD_PATTERNS.length; i++) {
106
+ if (PRD_PATTERNS[i].test(filename)) {
107
+ score += (PRD_PATTERNS.length - i) * 10; // 越靠前的模式分数越高
108
+ }
109
+ }
110
+
111
+ // 文件大小加分(PRD 通常内容较多)
112
+ try {
113
+ const stat = statSync(fullPath);
114
+ if (stat.size > 5000) score += 5; // > 5KB
115
+ if (stat.size > 20000) score += 5; // > 20KB
116
+ } catch {}
117
+
118
+ // 内容采样检测(读前 2000 字符)
119
+ if (score > 0) {
120
+ try {
121
+ const content = readFileSync(fullPath, 'utf-8').slice(0, 2000);
122
+ const keywords = ['功能', '需求', '用户', '模块', 'feature', 'requirement', 'user story', '技术栈', '架构'];
123
+ for (const kw of keywords) {
124
+ if (content.toLowerCase().includes(kw.toLowerCase())) score += 2;
125
+ }
126
+ } catch {}
127
+ }
128
+
129
+ return score;
130
+ }
131
+
132
+ /**
133
+ * 读取 PRD 文件内容
134
+ */
135
+ export function readPRD(prdPath) {
136
+ return readFileSync(prdPath, 'utf-8');
137
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Git 操作工具模块
3
+ */
4
+
5
+ import { execSync } from 'child_process';
6
+ import { log } from './logger.js';
7
+
8
+ function exec(cmd, cwd) {
9
+ return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
10
+ }
11
+
12
+ function execSafe(cmd, cwd) {
13
+ try {
14
+ return { ok: true, output: exec(cmd, cwd) };
15
+ } catch (err) {
16
+ return { ok: false, output: err.stderr || err.message };
17
+ }
18
+ }
19
+
20
+ /**
21
+ * 检查 git 仓库是否干净
22
+ */
23
+ export function isClean(cwd) {
24
+ const result = exec('git status --porcelain', cwd);
25
+ return result.length === 0;
26
+ }
27
+
28
+ /**
29
+ * 获取当前分支名
30
+ */
31
+ export function currentBranch(cwd) {
32
+ return exec('git branch --show-current', cwd);
33
+ }
34
+
35
+ /**
36
+ * 获取 remote 的 owner/repo
37
+ */
38
+ export function getRepoInfo(cwd) {
39
+ const url = exec('git remote get-url origin', cwd);
40
+ // 支持 https://github.com/owner/repo.git 和 git@github.com:owner/repo.git
41
+ const match = url.match(/github\.com[:/](.+?)\/(.+?)(?:\.git)?$/);
42
+ if (!match) throw new Error(`无法解析 GitHub 仓库地址: ${url}`);
43
+ return { owner: match[1], repo: match[2] };
44
+ }
45
+
46
+ /**
47
+ * 切换到目标分支(如不存在则创建)
48
+ */
49
+ export function checkoutBranch(cwd, branch, baseBranch = 'main') {
50
+ const current = currentBranch(cwd);
51
+ if (current === branch) return;
52
+
53
+ // 先切到 base 并拉取最新
54
+ execSafe(`git checkout ${baseBranch}`, cwd);
55
+ execSafe(`git pull origin ${baseBranch}`, cwd);
56
+
57
+ // 尝试切换,不存在则创建
58
+ const result = execSafe(`git checkout ${branch}`, cwd);
59
+ if (!result.ok) {
60
+ exec(`git checkout -b ${branch}`, cwd);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 提交所有变更
66
+ */
67
+ export function commitAll(cwd, message) {
68
+ exec('git add -A', cwd);
69
+ const result = execSafe(`git diff --cached --quiet`, cwd);
70
+ if (result.ok) {
71
+ log.dim('没有变更需要提交');
72
+ return false;
73
+ }
74
+ exec(`git commit -m "${message}"`, cwd);
75
+ return true;
76
+ }
77
+
78
+ /**
79
+ * 推送分支
80
+ */
81
+ export function pushBranch(cwd, branch) {
82
+ exec(`git push origin ${branch} --force-with-lease`, cwd);
83
+ }
84
+
85
+ /**
86
+ * 切回主分支
87
+ */
88
+ export function checkoutMain(cwd, baseBranch = 'main') {
89
+ execSafe(`git checkout ${baseBranch}`, cwd);
90
+ }
91
+
92
+ export const git = {
93
+ isClean, currentBranch, getRepoInfo, checkoutBranch,
94
+ commitAll, pushBranch, checkoutMain, exec, execSafe,
95
+ };