;
toggle: (path: string) => void;
@@ -132,6 +137,7 @@ function renderChildren(nodes: TreeNode[], depth: number, ctx: RenderCtx): React
const selected = ctx.selectedKey === fileKey(f);
const count = ctx.commentCountByPath.get(f.path) ?? 0;
const draftCount = ctx.draftCountByPath.get(f.path) ?? 0;
+ const conflict = ctx.conflictPaths.has(f.path) || (!!f.oldPath && ctx.conflictPaths.has(f.oldPath));
out.push(
)}
+ {conflict && (
+
+
+
+ )}
{
+ const [paths, setPaths] = useState>(() => new Set());
+
+ useEffect(() => {
+ if (!pr.hasConflict) {
+ setPaths(new Set());
+ return;
+ }
+ let cancelled = false;
+ invoke('diff:listConflictFiles', { localId: pr.localId })
+ .then((list) => {
+ if (!cancelled) setPaths(new Set(list));
+ })
+ .catch((e: unknown) => {
+ if (!cancelled) {
+ console.warn('diff:listConflictFiles failed', e);
+ setPaths(new Set());
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [pr.localId, pr.hasConflict]);
+
+ return paths;
+}
diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
index 05797066..fc29fd37 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
@@ -421,6 +421,7 @@
"fileTree": {
"commentCountTitle_one": "{{count}} Remote-Kommentar",
"commentCountTitle_other": "{{count}} Remote-Kommentare",
+ "conflictTitle": "Konflikt beim Merge in den Zielbranch",
"draftCountTitle_one": "{{count}} Entwurf zur Veröffentlichung ausstehend",
"draftCountTitle_other": "{{count}} Entwürfe zur Veröffentlichung ausstehend"
},
diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json
index f9f47dd4..607a9594 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json
@@ -421,6 +421,7 @@
"fileTree": {
"commentCountTitle_one": "{{count}} remote comment",
"commentCountTitle_other": "{{count}} remote comments",
+ "conflictTitle": "Conflicts when merged into the target branch",
"draftCountTitle_one": "{{count}} draft pending publish",
"draftCountTitle_other": "{{count}} drafts pending publish"
},
diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
index f3f76191..49a10ae3 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
@@ -411,6 +411,7 @@
},
"fileTree": {
"commentCountTitle_other": "{{count}} 件のリモートコメント",
+ "conflictTitle": "ターゲットブランチへのマージで競合します",
"draftCountTitle_other": "{{count}} 件の公開待ち下書き"
},
"inlineCodeContext": {
diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
index 1064cfad..30b4cac8 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
@@ -411,6 +411,7 @@
},
"fileTree": {
"commentCountTitle_other": "{{count}} 条远端评论",
+ "conflictTitle": "合并到目标分支会产生冲突",
"draftCountTitle_other": "{{count}} 条待发布草稿"
},
"inlineCodeContext": {
diff --git a/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss b/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss
index d579a670..7eaa1485 100644
--- a/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss
+++ b/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss
@@ -305,6 +305,14 @@
flex-shrink: 0;
}
+// 合并冲突警示:紧贴状态点左侧的三角叹号,用 warning 色提示该文件合并会冲突。
+.tree-conflict {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ color: $color-warning;
+}
+
// 评论 chip + 状态点的右侧容器;横向滚动时固定在视口右边缘,左缘渐变淡出,
// 遮盖背后滚出来的长文件名。背景跟随 .tree-row 状态 (默认/hover/selected)。
.tree-row-right {
diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts
index 4bbaf30e..ee7c71a0 100644
--- a/packages/ipc/src/pr.ts
+++ b/packages/ipc/src/pr.ts
@@ -91,6 +91,15 @@ export interface PrChannels {
request: { localId: string; base?: string; head?: string };
response: DiffChangedFile[];
};
+ /**
+ * 列出合并到目标分支会产生冲突的文件路径(PR 目标 tip ⟂ 源 head 的 `git merge-tree` 试合并)。
+ * 仅 `pr.hasConflict` 为真时才实际跑 merge-tree,否则直接返回空数组(省一次本地试合并)。
+ * 试合并失败 / 无法判定时返回空数组(保守不标冲突),文件树据此在对应行标三角警示图标。
+ */
+ 'diff:listConflictFiles': {
+ request: { localId: string };
+ response: string[];
+ };
/**
* 读取 base 或 head 一侧某文件的内容(二进制返回 {binary:true})。默认取 PR base / head 一侧;
* 传 base / head sha 则按指定范围取(commit 视图:base=parent、head=commit)。
diff --git a/packages/repo-mirror/src/repo-mirror-manager.ts b/packages/repo-mirror/src/repo-mirror-manager.ts
index 65762a1e..087a6a22 100644
--- a/packages/repo-mirror/src/repo-mirror-manager.ts
+++ b/packages/repo-mirror/src/repo-mirror-manager.ts
@@ -1,5 +1,7 @@
+import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
+import { promisify } from 'node:util';
import simpleGit, { type SimpleGit } from 'simple-git';
import type { Logger } from 'pino';
import type { SyncProgressEvent } from '@meebox/shared';
@@ -43,6 +45,9 @@ const GIT_UNSAFE_ENV_KEYS = new Set([
'prefix',
]);
+/** Promise 版 execFile:用于 simple-git 会因非零退出吞掉 stdout 的命令(如 merge-tree 冲突时退出码 1)。 */
+const execFileAsync = promisify(execFile);
+
/** 从 env 里剔除 simple-git 会拦的危险 key(大小写不敏感)。 */
function stripGitUnsafeEnv(env: Record): Record {
const out: Record = {};
@@ -452,6 +457,43 @@ export class RepoMirrorManager {
return parseNameStatusZ(out);
}
+ /**
+ * 列出把源 head 合并进目标 tip 会冲突的文件路径(`git merge-tree --write-tree` 的试合并,git ≥ 2.38)。
+ * 无冲突(退出码 0)/ 无法判定(退出码 < 0 或 git 过旧)→ 返回空数组,由调用方保守不标记。
+ *
+ * merge-tree 冲突时退出码为 1 且把结果写到 stdout,simple-git 会因非零退出吞掉 stdout,故直接走
+ * execFile 自行捕获 stdout。`-z` 让输出 NUL 分隔(路径含空格/中文/引号都不破),`--name-only` 只出冲突
+ * 文件名:首字段是结果 tree OID,随后是各冲突文件名,遇空字段(段分隔的双 NUL)即冲突文件段结束。
+ */
+ async listConflictFiles(
+ repo: RepoIdentity,
+ targetSha: string,
+ sourceSha: string,
+ ): Promise {
+ if (!targetSha || !sourceSha) return [];
+ const mirrorPath = this.mirrorPath(repo);
+ try {
+ // 退出码 0 = 干净可合并,无冲突。
+ await execFileAsync(
+ 'git',
+ ['merge-tree', '--write-tree', '--name-only', '-z', targetSha, sourceSha],
+ { cwd: mirrorPath, maxBuffer: 64 * 1024 * 1024 },
+ );
+ return [];
+ } catch (err) {
+ const e = err as { code?: number | string; stdout?: string | Buffer };
+ // 退出码 1 = 存在冲突,stdout 携带冲突文件段;其余(无法完成试合并 / git 过旧)保守返回空。
+ if (e.code === 1 && e.stdout != null) {
+ return parseMergeTreeConflictsZ(e.stdout.toString());
+ }
+ this.opts.logger?.warn(
+ { err, repo: this.repoKey(repo), targetSha, sourceSha },
+ 'git merge-tree conflict probe failed; treating as no conflict',
+ );
+ return [];
+ }
+ }
+
/**
* 读取某文件在某 commit 的内容。完整 bare clone 下 blob 都在本地,直接 git show。
* 文件不在该 commit (新增/删除场景) 返回空 content。
@@ -772,6 +814,22 @@ function parseNameStatusZ(raw: string): ChangedFile[] {
return out;
}
+/**
+ * 解析 `git merge-tree --write-tree --name-only -z` 在冲突时的 stdout。
+ * 格式(NUL 分隔):`<结果 tree OID>\0<冲突文件名>\0...\0\0<提示信息段...>`——首字段是 tree OID,随后
+ * 是各冲突文件名,遇空字段(段间双 NUL)即冲突文件段结束,后续提示信息段忽略。同名去重。
+ */
+export function parseMergeTreeConflictsZ(raw: string): string[] {
+ const parts = raw.split('\0');
+ const files: string[] = [];
+ // parts[0] = 结果 tree OID;从下一字段起收集冲突文件名,遇空字段(段分隔)停止。
+ for (let i = 1; i < parts.length; i++) {
+ if (parts[i] === '') break;
+ files.push(parts[i]!);
+ }
+ return [...new Set(files)];
+}
+
/**
* 解析 `git blame --porcelain` 输出。每个 hunk 头形如:
* ` []`
diff --git a/packages/repo-mirror/tests/repo-mirror-manager.test.ts b/packages/repo-mirror/tests/repo-mirror-manager.test.ts
index 889dae0c..8126ade0 100644
--- a/packages/repo-mirror/tests/repo-mirror-manager.test.ts
+++ b/packages/repo-mirror/tests/repo-mirror-manager.test.ts
@@ -7,6 +7,7 @@ import {
RepoMirrorManager,
parseBlamePorcelain,
parseHunkAddedLines,
+ parseMergeTreeConflictsZ,
} from '../src/repo-mirror-manager.js';
import type { RepoIdentity } from '../src/types.js';
@@ -359,6 +360,57 @@ describe('RepoMirrorManager diff/content', () => {
const r = await mgr.getFileContent(repo, sha, 'icon.png');
expect(r.binary).toBe(true);
});
+
+ it('parseMergeTreeConflictsZ 取首 OID 后到段分隔双 NUL 间的冲突文件名(去重)', () => {
+ // `git merge-tree --write-tree --name-only -z` 冲突时的 stdout:OID\0 file\0 \0(段分隔) 提示...
+ const raw =
+ '4530c9c9c26e09ddc2340fd825c09a190039d7d2\0f.txt\0src/x y.ts\0\0' +
+ '1\0f.txt\0CONFLICT (content): Merge conflict in f.txt\0';
+ expect(parseMergeTreeConflictsZ(raw)).toEqual(['f.txt', 'src/x y.ts']);
+ });
+
+ it('parseMergeTreeConflictsZ 无冲突文件(首字段后即段分隔)返回空数组', () => {
+ expect(parseMergeTreeConflictsZ('4530c9c9\0\0info')).toEqual([]);
+ });
+
+ it('listConflictFiles 列出试合并到目标分支会冲突的文件', async () => {
+ const upstream = simpleGit(upstreamPath);
+ await upstream.addConfig('commit.gpgsign', 'false', false, 'local');
+ // 目标分支名(init 默认 master / main,环境而定),后续 checkout 回它。
+ const main = (await upstream.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
+ // base:两个文件
+ await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb\nc\n');
+ await fs.writeFile(path.join(upstreamPath, 'solo.txt'), 'x\n');
+ await upstream.add('.');
+ await upstream.commit('conflict base');
+ const baseSha = (await upstream.revparse(['HEAD'])).trim();
+
+ // feature 分支:改 shared.txt 第二行 + 改 solo.txt
+ await upstream.checkoutLocalBranch('feature');
+ await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb-feature\nc\n');
+ await fs.writeFile(path.join(upstreamPath, 'solo.txt'), 'x-feature\n');
+ await upstream.add('.');
+ await upstream.commit('feature edit');
+ const featureSha = (await upstream.revparse(['HEAD'])).trim();
+
+ // 回目标分支并对 shared.txt 同一行做冲突改动;solo.txt 不动 → 仅 shared.txt 冲突
+ await upstream.checkout([main]);
+ await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb-main\nc\n');
+ await upstream.add('.');
+ await upstream.commit('main edit');
+ const targetSha = (await upstream.revparse([main])).trim();
+ expect(targetSha).not.toBe(baseSha);
+
+ const mgr = makeManager();
+ await mgr.syncMirror(repo);
+
+ const conflicts = await mgr.listConflictFiles(repo, targetSha, featureSha);
+ expect(conflicts).toContain('shared.txt');
+ expect(conflicts).not.toContain('solo.txt');
+
+ // 无冲突方向(同一分支与自身)→ 空
+ expect(await mgr.listConflictFiles(repo, targetSha, targetSha)).toEqual([]);
+ });
});
describe('RepoMirrorManager.materializeWorktree', () => {