import { run, runOrThrow, log, maskSecrets } from './util.js'; /** * 封裝這個 action 需要的 git 操作。所有對遠端的操作都透過 * http.extraheader 帶上 Gitea token,避免 token 寫進 remote URL。 */ export class Git { /** * @param {object} opts * @param {string} opts.cwd 工作目錄(已 checkout 的 repo) * @param {string} opts.remoteUrl 不含認證資訊的 repo HTTPS URL * @param {string} opts.token Gitea token */ constructor({ cwd, remoteUrl, token }) { this.cwd = cwd; this.remoteUrl = remoteUrl; this.token = token; // Gitea 接受 "Authorization: token " this.authArgs = ['-c', `http.extraheader=Authorization: token ${token}`]; } /** 帶 token 的 git 執行(用於遠端操作),不會把 args 印進日誌。 */ _authGit(args, { throwOnError = true } = {}) { const full = [...this.authArgs, ...args]; const result = run('git', full, { cwd: this.cwd }); if (throwOnError && result.status !== 0) { const detail = maskSecrets(result.stderr || result.stdout, [this.token]).trim(); throw new Error(`git ${args.join(' ')} 失敗 (${result.status}):\n${detail}`); } return result; } /** 不帶 token 的本地 git 執行。 */ _git(args, opts = {}) { return run('git', args, { cwd: this.cwd, ...opts }); } /** 初始化必要的 git 設定(safe.directory、user.name/email)。 */ configure() { run('git', ['config', '--global', '--add', 'safe.directory', this.cwd]); run('git', ['config', '--global', '--add', 'safe.directory', '*']); // 解衝突分支需要建立 merge commit,必須有身份 this._git(['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'ai-pull-request[bot]']); this._git(['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'ai-pull-request@users.noreply.gitea']); } /** * 從遠端抓取 source 與 target 分支到本地追蹤分支 refs/remotes/pr/。 * * @param {string[]} branches */ fetchBranches(branches) { const refspecs = branches.map((b) => `+refs/heads/${b}:refs/remotes/pr/${b}`); log.info(`抓取分支: ${branches.join(', ')}`); this._authGit(['fetch', '--no-tags', this.remoteUrl, ...refspecs]); } /** 取得分支的 commit 數量差異(source 比 target 多幾個 commit)。 */ countAheadCommits(target, source) { const result = this._git(['rev-list', '--count', `refs/remotes/pr/${target}..refs/remotes/pr/${source}`]); return result.status === 0 ? parseInt(result.stdout.trim(), 10) || 0 : 0; } /** 取得 source 相對 target 的 commit 訊息清單。 */ getCommitMessages(target, source, limit = 50) { const result = this._git([ 'log', `--max-count=${limit}`, '--pretty=format:- %s', `refs/remotes/pr/${target}..refs/remotes/pr/${source}`, ]); return result.status === 0 ? result.stdout.trim() : ''; } /** 取得 diff 統計(--stat)。 */ getDiffStat(target, source) { const result = this._git([ 'diff', '--stat', `refs/remotes/pr/${target}...refs/remotes/pr/${source}`, ]); return result.status === 0 ? result.stdout.trim() : ''; } /** 取得完整 diff(three-dot,等同 PR 在 merge base 之後的變更)。 */ getDiff(target, source) { const result = this._git([ 'diff', `refs/remotes/pr/${target}...refs/remotes/pr/${source}`, ]); return result.status === 0 ? result.stdout : ''; } /** * 偵測 source 合併進 target 是否會衝突(不會留下任何變更)。 * * @returns {{ hasConflict: boolean, files: string[] }} */ detectConflict(target, source) { // 建立暫時的本地 target 分支,嘗試以 --no-commit 合併 source。 // 名稱帶 PID,避免並行或前次殘留造成命名衝突。 const tmp = `__conflict_check_${target}_${process.pid}`; this._git(['checkout', '-B', tmp, `refs/remotes/pr/${target}`]); try { const merge = this._git(['merge', '--no-commit', '--no-ff', `refs/remotes/pr/${source}`]); const hasConflict = merge.status !== 0; let files = []; if (hasConflict) { const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']); files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean); } return { hasConflict, files }; } finally { // 無論成敗都還原工作區並清除暫存分支 this._git(['merge', '--abort']); this._git(['checkout', '--detach']); this._git(['branch', '-D', tmp]); } } /** * 建立解衝突分支:以 target 為基礎,合併 source(保留衝突標記後 commit), * 再推送到遠端。 * * @param {object} opts * @param {string} opts.target 目標分支 * @param {string} opts.source 來源分支 * @param {string} opts.resolveBranch 解衝突分支名稱 * @returns {{ files: string[] }} 衝突檔案清單 */ createResolveBranch({ target, source, resolveBranch }) { log.info(`以 ${target} 為基礎建立解衝突分支 ${resolveBranch}`); this._git(['checkout', '-B', resolveBranch, `refs/remotes/pr/${target}`]); const merge = this._git([ 'merge', '--no-ff', '-m', `Merge branch '${source}' into ${resolveBranch} (待人工解衝突)`, `refs/remotes/pr/${source}`, ]); let files = []; if (merge.status !== 0) { // 合併產生衝突:將含有衝突標記的檔案標記為已解決後 commit, // 讓開發者可以在 PR 中看到並解決衝突。 const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']); files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean); runOrThrow('git', ['add', '-A'], { cwd: this.cwd }); runOrThrow( 'git', ['commit', '--no-verify', '-m', `Merge branch '${source}' into ${resolveBranch}(含衝突標記,待人工解衝突)`], { cwd: this.cwd }, ); } log.info(`推送解衝突分支 ${resolveBranch}`); this._authGit(['push', '--force', this.remoteUrl, `HEAD:refs/heads/${resolveBranch}`]); return { files }; } }