Files
ai-pull-request/app/lib/git.js
T

164 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <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/<branch>。
*
* @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() : '';
}
/** 取得完整 diffthree-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
const tmp = `__conflict_check_${target}`;
this._git(['checkout', '-B', tmp, `refs/remotes/pr/${target}`]);
const merge = this._git(['merge', '--no-commit', '--no-ff', `refs/remotes/pr/${source}`]);
let 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);
}
// 還原工作區
this._git(['merge', '--abort']);
this._git(['checkout', '--detach']);
this._git(['branch', '-D', tmp]);
return { hasConflict, files };
}
/**
* 建立解衝突分支:以 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 };
}
}