Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@

在 Windows 版里,当一个工作区包含多个 Git 仓库时,Git Log 的引用树按仓库分组:
顶层每个仓库一个可折叠节点(仓库名 + 引用数),节点内部才是 Local / Remote / Tags。
分支数据由前端遍历工作区已发现的仓库分别读取再聚合,Rust Core 的引用与历史命令
仍是单仓库接口,`GitReference` 只在浏览器侧多带一个可选的 `repositoryPath`。
分支数据由前端按仓库分别读取再聚合,Rust Core 的引用与历史命令仍是单仓库接口,
`GitReference` 只在浏览器侧多带一个可选的 `repositoryPath`。
点击其它仓库的分支会把活动仓库切换过去,再加载该分支的历史。

聚合读取必须**串行**:解析仓库路径会执行一条持有"仓库级写租约"的 Git 命令,而该
租约按 Git 公共目录(common dir)加锁。一个仓库的多个链接工作树共享同一个公共目录,
仓库引用是**按需加载**的:打开 Git Log 时只读活动仓库;其它仓库要等它在引用树里
可见且展开时才读,读到后缓存,直到该仓库发生 Git 变更才刷新。这样打开大型多仓库
工作区不再一次性发起 N 次 Git 读。

读取必须**串行**:解析仓库路径会执行一条持有"仓库级写租约"的 Git 命令,而该租约
按 Git 公共目录(common dir)加锁。一个仓库的多个链接工作树共享同一个公共目录,
并发解析会互相报 "another Git write operation is running",导致工作树取不到引用而显示 0 条。
按需加载不改变这一点:所有加载请求(活动仓库首读、展开时的按需读、变更刷新)
都排进同一条串行队列。

## 问题

Expand Down Expand Up @@ -48,9 +54,30 @@ Windows 前端完成:新 hook `windows/tauri/src/features/git/hooks/use-git-wo
工作区里的链接工作树共享主仓库的 Git 公共目录。解析仓库路径(前端
`resolveRepositoryPath` → 原生 `git_discover_repo` → Core `git.command` 执行
`rev-parse --show-toplevel`)会持有按公共目录加锁的写租约;若并发解析同一公共目录
下的多个工作树,除第一个外都会失败。因此 hook 按 `availableRepoPaths` **顺序**加载,
首轮和变更刷新都串行。工作树本身是合法仓库(Core 的发现明确返回 `.git` 文件形式的工作树),
修复方向是让读取不互相冲突,而不是把工作树从仓库列表里剔除。
下的多个工作树,除第一个外都会失败。因此 `useGitWorkspaceReferences` 把所有读取
请求排进一条 Promise 串行队列(`loadChainRef`),活动仓库首读、展开时的按需读、
变更刷新都从同一条队列出队,任何时刻只有一个 Git 读在飞。工作树本身是合法仓库
(Core 的发现明确返回 `.git` 文件形式的工作树),修复方向是让读取不互相冲突,
而不是把工作树从仓库列表里剔除。

### 只有活动仓库预先加载,其余按展开加载

首版实现一打开 Git Log 就遍历 `availableRepoPaths` 把所有仓库读一遍,仓库一多就是
N 次串行 Git 读。现在 hook 只主动读**活动仓库**,其它仓库通过返回的
`ensureRepository(repositoryPath)` 按需触发:引用树在某个仓库分组可见且未折叠时
调用它,展开折叠的分组也会触发。已请求过的仓库记录在 `requestedRepositoryKeysRef`
里,重复请求直接返回,形成缓存;仓库从工作区移除时清掉该记录并取消它自己的在飞操作。
Git 变更刷新只作用于已请求过的仓库。活动仓库改变或列表变化时,活动仓库会被重新
确保加载。

活动仓库的引用同时由 `useGitLogController` 提供并被引用树直接使用,hook 侧对活动
仓库的读取主要用于保持"活动仓库总是已加载"这一不变式和刷新语义一致。

读取串行执行(同一个 common dir 的兄弟工作树读引用会互相抢仓库写租约),所以一次请求
可能要排在前面若干次读取之后才真正开始。**排队不等于已经发出**:仓库代数在入队时就占住,
真正调用原生 API 前要重新核对"面板没卸载、仓库还在工作区、没有更新的请求顶替"。三者任一
不成立就丢掉这条排队项,并把它占用的 pending 计数还回去。否则会出现:A 的读取挂起、B 入队、
面板关闭,A 取消完成后 B 仍会发起一次没有主人负责取消的原生读取。

### 点击其它仓库的分支要切换活动仓库

Expand All @@ -70,21 +97,29 @@ pending 引用 ref,在 `repoPath` 变化后的 effect 里调用 `selectReferen
- **让 Core 的仓库发现跳过 `.git` 文件(工作树)**:被否。Core 现有集成测试
`workspace_repositories_discovers_multiple_child_repositories` 明确要求发现工作树标记,
契约文档也写明 `.git` 目录和 `.git` 文件都算仓库标记;剔除工作树会破坏该既定行为。
- **打开时一次性加载所有仓库(初版实现)**:被否。仓库越多首屏要串行等待越多,
而大多数仓库分组是折叠的、用户不会去看;改为只预读活动仓库,其余按展开加载,
代价是展开时多一次读。

## 后果

- 多仓库工作区现在能一次看到所有仓库的分支,并知道每条分支属于哪个仓库;工作树
作为独立仓库照常出现,且能看到与主仓库共享的分支。
- 多仓库工作区能按仓库看到分支,并知道每条分支属于哪个仓库;工作树作为独立仓库
照常出现,且能看到与主仓库共享的分支。
- 单仓库用户不受影响:引用树不出现仓库层,行为与此前一致。
- 各仓库引用顺序读取,仓库越多总耗时越长;换来的是同一公共目录下不互相抢租约。
- 只在 `repositoryPaths.length > 1` 时走仓库分组与多仓库读取。
- 打开 Git Log 只读活动仓库,其余仓库在首次展开时读一次并缓存;仓库越多,
首屏越快,代价是展开新仓库时有一次可见延迟(先显示 0 条再填充)。
- 各仓库引用仍顺序读取,换来的是同一公共目录下不互相抢租约。
- 只在 `repositoryPaths.length > 1` 时走仓库分组与多仓库按需读取。
- macOS 端尚未做对应的引用树分组,行为与 Windows 暂不一致。

## 验证

- Windows 前端:`tsc --noEmit`;`bun test src/features/git`,含新增的
`git-reference-tree.test.tsx`(单仓库保持扁平、多仓库按仓库分组)与
`use-git-workspace-references.test.tsx`。
- Windows 前端:`tsc --noEmit`;`bun test src/features/git`,含
`git-reference-tree.test.tsx`(单仓库保持扁平、多仓库按仓库分组)、
`git-reference-tree-lazy-references.test.tsx`(可见的展开分组触发加载、折叠分组
展开后才加载)与 `use-git-workspace-references.test.tsx`(只加载活动仓库、
按需加载、只刷新已加载仓库、错误重试,以及排队项在面板卸载 / 仓库移出工作区后
不得发起原生读取且要归还 pending 计数)。
- Rust 未改动(Core 的仓库发现和引用契约保持原样),`cargo test --manifest-path
rust/lithe-core/Cargo.toml` 无需针对本改动重跑。
- 手工:在工作区 `D:/workspace/work-code/op` 打开 Git Log,确认仓库分组、工作树
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Agent 笔记:Windows Git 提交图按分支名固定颜色

状态:已实现

关联:Windows 侧对齐 macOS 的 IDEA 风格图着色,见
[`2026-09-13-macos-git-graph-intellij-layout.md`](2026-09-13-macos-git-graph-intellij-layout.md)。

## 先说结论

Windows Git 提交图不再按"第几条泳道"轮换颜色,而是用泳道所代表分支的引用名
(Java `String.hashCode`)决定颜色。分支换到别的泳道、历史分页或拓扑变化时,
同一条分支保持同一种颜色;没有引用名的泳道用一个确定性的兜底序号。

## 问题

`windows/tauri/src/features/git/utils/git-graph-layout.ts` 原先把颜色按泳道槽位分配:
每新占一个槽位就 `colorIndex++`。泳道编号会随合并顺序、分页和可见提交集合变化,
于是同一条分支的颜色会跳变,用户看到的图与 macOS/IDEA 不一致。

## 决策

- 颜色身份来自引用名,不来自泳道序号。每条提交解析其装饰(`parseGitDecorations`),
按 IDEA 的优先级选出"主引用":`origin/main`/`origin/master` → 其他远程分支 →
本地 `main`/`master` → 其他本地分支 → tag → HEAD。
- 用主引用名的 Java `String.hashCode`(按 UTF-16 code unit 计算,`git-graph-colors.ts`
的 `javaStringHashCode`)对现有 6 色调色板取模,得到 `colorIndex`
(`graphColorIndexForName`)。复用 `git-graph-row.tsx` 原有的调色板与线宽,
不引入新颜色或色相算法。
- 颜色在**泳道建立时**确定:非首个父提交占新槽位时用该提交的主引用名上色;
首个父提交延续当前泳道颜色(与 macOS 的图头片段沿用同一颜色一致)。
- 没有引用名的提交(合并的第二父、快照外的父提交等)用自增兜底序号
(`fallbackColorIndex`),只保证同输入同输出,不承诺跨拓扑稳定。
- 调色板、`graphColor` 与哈希函数集中到
`windows/tauri/src/features/git/utils/git-graph-colors.ts`,供布局与行渲染共用。

## 考虑过的备选方案

- **沿用 macOS 的整数 RGB → hue 方案(`GitGraphColor.swift`)**:能产生更多互不
相同的颜色,但会改变 Windows 现有的 6 色调色板与视觉语言,还需要跨平台同步
主题覆盖值。本次只要求颜色稳定,因此保留既有调色板,只改颜色身份来源。
- **按提交 hash 取模**:实现最简单,但会让相邻的无关分支碰巧同色,历史一变化
颜色仍会跳。因此改为按引用名。
- **未引用泳道也按某个假名哈希**:不如自增序号直观,也无法保证首帧稳定,
故采用确定性兜底序号。

## 后果

- 同一条命名分支在分页、筛选、拓扑变化后颜色不变;颜色只取决于引用名。
- 6 色调色板下不同分支仍可能撞色(这是既有视觉约束,非回归)。
- `GitGraphEdge.colorIndex` / `GitGraphRow.incomingLaneColors` 的语义从"泳道序号"
变为"调色板槽位",取值 0–5 或兜底序号;未引用的兜底序号与命名分支的槽位
可能重叠。
- 单仓库、单分支等简单历史行为不变(颜色仍是调色板第 0 色时可能的等价结果)。

## 验证

- Windows 前端:`tsc --noEmit`;`bun test src/features/git`,其中
`git-graph-layout.test.ts` 覆盖"同一分支名在不同拓扑落到不同泳道时颜色一致"、
"未引用泳道确定性"、"Java `String.hashCode` 取值"与"主引用优先级选择"。

## 适用范围

- `windows/tauri/src/features/git/utils/git-graph-colors.ts`
- `windows/tauri/src/features/git/utils/git-graph-layout.ts`
- `windows/tauri/src/features/git/components/log/git-graph-row.tsx`

不改变 Rust Core、JSON 契约或 macOS 实现;Windows 的布局仍是泳道槽位算法,
不是 macOS 的仓库级永久图。
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import { useFileSystemStore } from "@/features/file-system/stores/file-system.st
import { LocalHistoryCommandContent } from "@/features/local-history/components/local-history-command";
import { OutlineCommandContent } from "@/features/outline/components/outline-command";
import { commitChanges } from "@/features/git/api/git-commits-api";
import { fetchChanges, pullChanges } from "@/features/git/api/git-remotes-api";
import { fetchChanges } from "@/features/git/api/git-remotes-api";
import { showGitPushDialog } from "@/features/git/services/git-push-dialog-service";
import { showGitPullDialog } from "@/features/git/services/git-pull-dialog-service";
import {
discardAllChanges,
stageAllFiles,
Expand Down Expand Up @@ -318,7 +319,7 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont
unstageAllFiles,
commitChanges,
showGitPushDialog,
pullChanges,
showGitPullDialog,
fetchChanges,
discardAllChanges,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
ArrowClockwiseIcon as RefreshCw,
} from "@/ui/icons";
import type { GitRemoteActionResult } from "@/features/git/api/git-remotes-api";
import { getGitPullResultPresentation } from "@/features/git/utils/git-pull-result-presentation";
import type { GitPullDialogResult } from "@/features/git/services/git-pull-dialog-service";
import { useSettingsStore } from "@/features/settings/stores/settings.store";
import { createTranslator } from "@/i18n/locale";
import { showConfirmDialog, showPromptDialog } from "@/ui/dialog";
Expand All @@ -31,7 +31,7 @@ interface GitActionsParams {
unstageAllFiles: (path: string) => Promise<boolean>;
commitChanges: (path: string, message: string) => Promise<boolean>;
showGitPushDialog: (path: string) => Promise<boolean>;
pullChanges: (path: string) => Promise<GitRemoteActionResult>;
showGitPullDialog: (path: string) => Promise<GitPullDialogResult>;
fetchChanges: (path: string) => Promise<GitRemoteActionResult>;
discardAllChanges: (path: string) => Promise<boolean>;
};
Expand Down Expand Up @@ -369,39 +369,15 @@ export const createGitActions = (params: GitActionsParams): Action[] => {
description: "Pull changes from remote",
icon: <RefreshCw />,
category: "Git",
action: async () => {
action: () => {
if (!repoPath) {
showToast({ message: t("git.noRepositoryOpen"), type: "error" });
onClose();
return;
}
try {
showToast({ message: t("git.pullingChanges"), type: "info" });
const result = await gitOperations.pullChanges(repoPath);
if (result.success) {
showToast({ message: t("git.pulledChanges"), type: "success" });
} else {
const presentation = result.pullResult
? getGitPullResultPresentation(result.pullResult, t)
: null;
const requiresStrategy = result.pullResult?.status === "cancelled";
showToast({
message:
presentation?.message ||
(requiresStrategy ? t("git.pullResult.strategyRequired") : result.error) ||
t("git.pullResult.pullFailed", {
error: t("git.pullResult.gitRejectedPull"),
}),
type:
presentation?.tone === "info" || presentation?.tone === "warning"
? "info"
: "error",
});
}
} catch (error) {
showToast({ message: t("git.operationError", { error: String(error) }), type: "error" });
}
onClose();
// The shared Pull dialog owns branch, strategy, and result reporting.
void gitOperations.showGitPullDialog(repoPath);
},
},
{
Expand Down
Loading
Loading