diff --git a/scripts/merge-to-production.js b/scripts/merge-to-production.js index a520437..ae0f986 100644 --- a/scripts/merge-to-production.js +++ b/scripts/merge-to-production.js @@ -1,6 +1,7 @@ #!/usr/bin/env node -const { execSync } = require('child_process'); +const readline = require('readline'); +const { spawnSync } = require('child_process'); const COLORS = { red: '\x1b[31m', @@ -10,116 +11,336 @@ const COLORS = { reset: '\x1b[0m' }; +class MergeConflictError extends Error { + constructor(branchName) { + super(`合并到 ${branchName} 时发生冲突`); + this.name = 'MergeConflictError'; + this.branchName = branchName; + } +} + function log(message, color = 'reset') { console.log(`${COLORS[color]}${message}${COLORS.reset}`); } -function exec(command, options = {}) { - try { - return execSync(command, { encoding: 'utf-8', stdio: options.silent ? 'pipe' : 'inherit', ...options }); - } catch (error) { - if (options.ignoreError) { - return error.stdout || ''; +function runCommand(command, args, options = {}) { + const { silent = false, ignoreError = false } = options; + const result = spawnSync(command, args, { + encoding: 'utf-8', + stdio: silent ? ['inherit', 'pipe', 'pipe'] : 'inherit' + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + if (ignoreError) { + return result.stdout || ''; } + + const error = new Error(`${command} ${args.join(' ')} failed`); + error.status = result.status; + error.stdout = result.stdout || ''; + error.stderr = result.stderr || ''; throw error; } + + return result.stdout || ''; +} + +function git(args, options = {}) { + return runCommand('git', args, options); +} + +function createPrompt() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + return { + ask(question) { + return new Promise(resolve => { + rl.question(question, answer => resolve(answer.trim())); + }); + }, + close() { + rl.close(); + } + }; } -function run() { - log('\n========================================', 'cyan'); - log(' 合并 master 到 production 分支', 'cyan'); - log('========================================\n', 'cyan'); +function getCurrentBranch() { + return git(['branch', '--show-current'], { silent: true }).trim(); +} - // 获取当前分支名 - const currentBranch = exec('git branch --show-current', { silent: true }).trim(); - log(`当前分支: ${currentBranch}\n`, 'cyan'); +function getLocalBranches() { + return git(['branch', '--format=%(refname:short)'], { silent: true }) + .split(/\r?\n/) + .map(item => item.trim()) + .filter(Boolean); +} + +function hasRemoteBranch(branchName) { + const output = git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branchName}`], { + silent: true, + ignoreError: true + }); - // 暂存未提交的更改 - const status = exec('git status --porcelain', { silent: true }); - const hasChanges = status.trim().length > 0; - if (hasChanges) { - log('>>> 暂存未提交的更改...', 'yellow'); - exec('git stash push -m "暂存未提交的更改"'); - log('已暂存\n', 'green'); + return output.trim().length > 0; +} + +function stashChanges(state) { + const status = git(['status', '--porcelain'], { silent: true }); + state.hasChanges = status.trim().length > 0; + + if (!state.hasChanges) { + return; } - // 拉取最新代码 - log('>>> 拉取远程最新代码...', 'yellow'); - try { - exec('git fetch origin'); - } catch (error) { - log('\n错误: 拉取远程代码失败', 'red'); - process.exit(1); + log('>>> 暂存未提交的改动...', 'yellow'); + git(['stash', 'push', '-m', 'auto-stash-before-merge']); + state.stashCreated = true; + log('已暂存\n', 'green'); +} + +function restoreWorkspace(state) { + if (state.currentBranch && getCurrentBranch() !== state.currentBranch) { + log(`>>> 切换回原分支 ${state.currentBranch}...`, 'yellow'); + git(['checkout', state.currentBranch], { ignoreError: true }); } + + if (state.stashCreated) { + log('>>> 恢复暂存的改动...', 'yellow'); + git(['stash', 'pop'], { ignoreError: true }); + log('已恢复\n', 'green'); + } +} + +function fetchOrigin() { + log('>>> 拉取远程最新信息...', 'yellow'); + git(['fetch', 'origin']); log('拉取完成\n', 'green'); +} - // 切换到 master 并更新 - log('>>> 切换到 master 分支并更新...', 'yellow'); - try { - exec('git checkout master'); - exec('git pull origin master'); - } catch (error) { - log('\n错误: 更新 master 分支失败', 'red'); - process.exit(1); +function checkoutBranch(branchName) { + log(`>>> 切换到 ${branchName} 分支...`, 'yellow'); + git(['checkout', branchName]); + log(`已切换到 ${branchName}\n`, 'green'); +} + +function pullBranchIfNeeded(branchName) { + if (!hasRemoteBranch(branchName)) { + log(`>>> 本地分支 ${branchName} 未关联 origin/${branchName},跳过 pull`, 'yellow'); + return; } - log('master 分支已更新\n', 'green'); - // 切换到 production 并更新 - log('>>> 切换到 production 分支并更新...', 'yellow'); - try { - exec('git checkout production'); - exec('git pull origin production'); - } catch (error) { - log('\n错误: 更新 production 分支失败,分支可能不存在', 'red'); - log('请先创建 production 分支: git checkout -b production\n', 'yellow'); - exec(`git checkout ${currentBranch}`, { ignoreError: true }); - process.exit(1); + log(`>>> 更新 ${branchName} 分支...`, 'yellow'); + git(['pull', 'origin', branchName]); + log(`${branchName} 已更新\n`, 'green'); +} + +function pushBranchIfNeeded(branchName) { + if (!hasRemoteBranch(branchName)) { + log(`>>> 本地分支 ${branchName} 未关联 origin/${branchName},跳过 push`, 'yellow'); + return; } - log('production 分支已更新\n', 'green'); - // 合并 master 到 production - log('>>> 合并 master 到 production...', 'yellow'); + log(`>>> 推送 ${branchName} 到远程...`, 'yellow'); + git(['push', 'origin', branchName]); + log(`${branchName} 推送成功\n`, 'green'); +} + +function printConflictGuidance(branchName) { + log(`\n错误: 合并到 ${branchName} 时发生冲突`, 'red'); + log('请手动解决冲突后再继续', 'red'); + log('\n冲突文件:', 'yellow'); + git(['diff', '--name-only', '--diff-filter=U'], { ignoreError: true }); + log('\n解决冲突后可继续执行:', 'yellow'); + log(' git add .', 'cyan'); + log(' git commit', 'cyan'); + log(` git push origin ${branchName}\n`, 'cyan'); + log('如需放弃本次合并:', 'yellow'); + log(' git merge --abort\n', 'cyan'); +} + +function mergeBranch(sourceBranch, targetBranch, state) { + log(`>>> 合并 ${sourceBranch} -> ${targetBranch}...`, 'yellow'); + try { - exec('git merge master --no-edit'); + git(['merge', sourceBranch, '--no-edit']); } catch (error) { - log('\n错误: 合并过程中发生冲突!', 'red'); - log('请手动解决冲突后再提交', 'red'); - log('\n冲突文件:', 'yellow'); - exec('git diff --name-only --diff-filter=U', { ignoreError: true }); - log('\n解决冲突后运行:', 'yellow'); - log(' git add .', 'cyan'); - log(' git commit', 'cyan'); - log(' git push origin production\n', 'cyan'); - log('或放弃合并:', 'yellow'); - log(' git merge --abort\n', 'cyan'); - process.exit(1); + state.shouldRestoreWorkspace = false; + throw new MergeConflictError(targetBranch); } + log('合并成功\n', 'green'); +} - // 推送到远程 - log('>>> 推送到远程 production 分支...', 'yellow'); - try { - exec('git push origin production'); - } catch (error) { - log('\n错误: 推送失败', 'red'); - process.exit(1); +function printMenu(title, options) { + log(`\n${title}`, 'cyan'); + options.forEach((option, index) => { + console.log(` ${index + 1}. ${option.label}`); + }); + console.log(''); +} + +async function selectOption(prompt, title, options) { + while (true) { + printMenu(title, options); + const answer = await prompt.ask('请输入编号并回车: '); + const index = Number(answer); + + if (Number.isInteger(index) && index >= 1 && index <= options.length) { + return options[index - 1]; + } + + log('输入无效,请重新选择。\n', 'red'); } - log('推送成功\n', 'green'); +} - // 切换回原分支 - log(`>>> 切换回原分支 ${currentBranch}...`, 'yellow'); - exec(`git checkout ${currentBranch}`, { ignoreError: true }); +async function selectBranch(prompt, title, branches, currentBranch) { + const options = branches.map(branchName => ({ + value: branchName, + label: branchName === currentBranch ? `${branchName} (当前分支)` : branchName + })); - // 恢复暂存的更改 - if (hasChanges) { - log('>>> 恢复暂存的更改...', 'yellow'); - exec('git stash pop', { ignoreError: true }); - log('已恢复\n', 'green'); + const selected = await selectOption(prompt, title, options); + return selected.value; +} + +async function confirm(prompt, message) { + while (true) { + const answer = (await prompt.ask(`${message} (y/n): `)).toLowerCase(); + + if (answer === 'y' || answer === 'yes') { + return true; + } + + if (answer === 'n' || answer === 'no') { + return false; + } + + log('请输入 y 或 n。\n', 'red'); + } +} + +async function runSingleBranchMerge(prompt, state) { + const branches = getLocalBranches(); + + if (branches.length < 2) { + throw new Error('本地分支数量不足,无法执行单分支合并'); + } + + const sourceBranch = await selectBranch(prompt, '请选择来源分支', branches, state.currentBranch); + const targetCandidates = branches.filter(branchName => branchName !== sourceBranch); + const targetBranch = await selectBranch(prompt, '请选择目标分支', targetCandidates, state.currentBranch); + + log(`\n将执行: ${sourceBranch} -> ${targetBranch}\n`, 'cyan'); + + const accepted = await confirm(prompt, '确认开始合并吗'); + if (!accepted) { + log('已取消操作', 'yellow'); + return; + } + + stashChanges(state); + fetchOrigin(); + checkoutBranch(targetBranch); + pullBranchIfNeeded(targetBranch); + mergeBranch(sourceBranch, targetBranch, state); + pushBranchIfNeeded(targetBranch); + + log('\n========================================', 'green'); + log(` 已完成 ${sourceBranch} -> ${targetBranch} 合并`, 'green'); + log('========================================\n', 'green'); +} + +async function runDeployMerge(prompt, state) { + if (state.currentBranch === 'production') { + throw new Error('当前分支为 production,不能执行部署合并'); } + log(`\n将执行部署合并: ${state.currentBranch} -> master -> production\n`, 'cyan'); + + const accepted = await confirm(prompt, '确认开始部署合并吗'); + if (!accepted) { + log('已取消操作', 'yellow'); + return; + } + + stashChanges(state); + fetchOrigin(); + + checkoutBranch('master'); + pullBranchIfNeeded('master'); + + if (state.currentBranch !== 'master') { + mergeBranch(state.currentBranch, 'master', state); + } else { + log('当前分支已是 master,跳过 feature -> master 合并\n', 'yellow'); + } + + pushBranchIfNeeded('master'); + + checkoutBranch('production'); + pullBranchIfNeeded('production'); + mergeBranch('master', 'production', state); + pushBranchIfNeeded('production'); + log('\n========================================', 'green'); - log(' master 已成功合并到 production!', 'green'); + log(` 已完成 ${state.currentBranch} -> master -> production`, 'green'); log('========================================\n', 'green'); } +async function run() { + const prompt = createPrompt(); + const state = { + currentBranch: '', + hasChanges: false, + stashCreated: false, + shouldRestoreWorkspace: true + }; + + try { + log('\n========================================', 'cyan'); + log(' Git 分支合并工具', 'cyan'); + log('========================================\n', 'cyan'); + + state.currentBranch = getCurrentBranch(); + + if (!state.currentBranch) { + throw new Error('当前不在任何本地分支上,无法执行合并'); + } + + log(`当前分支: ${state.currentBranch}\n`, 'cyan'); + + const action = await selectOption(prompt, '请选择操作类型', [ + { label: '单分支合并', value: 'single' }, + { label: '部署合并(当前分支 -> master -> production)', value: 'deploy' } + ]); + + if (action.value === 'single') { + await runSingleBranchMerge(prompt, state); + } else { + await runDeployMerge(prompt, state); + } + } catch (error) { + if (error instanceof MergeConflictError) { + printConflictGuidance(error.branchName); + } else { + log(`\n错误: ${error.message}\n`, 'red'); + } + + process.exitCode = 1; + } finally { + prompt.close(); + + if (state.shouldRestoreWorkspace) { + restoreWorkspace(state); + } + } +} + run();