diff --git a/.agents/notes/implemented/feature/2026-09-24-macos-git-log-multi-repository-groups.md b/.agents/notes/implemented/feature/2026-09-24-macos-git-log-multi-repository-groups.md
index 7c0eeca55..57df59ff5 100644
--- a/.agents/notes/implemented/feature/2026-09-24-macos-git-log-multi-repository-groups.md
+++ b/.agents/notes/implemented/feature/2026-09-24-macos-git-log-multi-repository-groups.md
@@ -15,6 +15,12 @@ Local / Remote / Tags。只有一个仓库时,渲染和以前完全一样,
只有“活动仓库”那一个分组可以执行分支写操作。其它仓库的分组是**只读**的:可以点选
某一行把它切成活动仓库,但右键不再弹出任何菜单,避免在 B 仓库的行上误操作 A 仓库。
+同一块引用面板还给每个仓库配一个颜色:工作区里有多个仓库时,仓库节点显示一个色点、
+该仓库下的每条引用行左侧有一条细色条;只有一个仓库时完全不出现颜色,和改动前一致。
+这次同时把引用行右键菜单补齐到 IntelliJ IDEA 的常见项:`Copy Branch Name`,以及
+“Tracking Branch”子菜单(列出远程分支来设置上游、带一个清除项);提交右键的“重置到
+此处”也补齐了 soft / mixed / hard 三档。
+
## 问题
工作区下可以有多个并列仓库,其中主仓库还常带一批链接工作树(位于主仓库目录内的
@@ -104,6 +110,69 @@ rename / delete)都只针对一个活动仓库,闭包直接从活动 `gitRep
与标题、闭包和本地化解耦,因此“只读仓库不提供任何条目”这条规则可以在没有 SwiftUI
宿主的情况下直接单元测试。视图只负责把每个条目映射成真实的 `LitheContextMenuItem`。
+### 每个仓库一个颜色,定位用“完整仓库列表”而不是“可见仓库列表”
+
+多仓库时,仓库节点带一个 8 色色点(`GitRepositoryColor`,定义在
+`macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift`),该仓库下的每条引用行左侧
+有一条 2.5px 色条,方便一眼看出某条引用属于哪个仓库。
+
+颜色按仓库在 `feature.availableRepositoryRoots` 里的**位置**取
+`palette[index % 8]`,按标准化路径匹配,而不是按“当前可见的仓库”重新编号。原因是
+`availableRepositoryRoots` 是**工作树过滤之前**的完整顺序列表:如果按可见集合编号,
+隐藏工作树时剩余仓库的颜色会整体前移、颜色跟着行漂移,用户会以为引用换了仓库。正确做法
+是把完整列表传给 `GitRepositoryColor.index(for:in:)`;错误做法是用
+`GitRepositoryHierarchy.visibleRepositoryRoots(...)` 的结果去定色。
+
+颜色只在可见仓库数 `> 1` 时出现:仓库节点判断 `GitRepositoryColor.isVisible(for:)`
+(即完整列表 `count > 1`),单仓库布局把 `repositoryColorIndex` 传 `nil`,因此单仓库
+工作区的渲染和改动前完全一样,没有色点也没有色条。
+
+`GitReferenceRowView`(`GitLogView.swift`)因此新增 `repositoryColorIndex: Int?` 入参,
+并且**必须纳入它的自定义 `==`**:只有把色号纳入相等比较,隐藏 / 显示工作树导
+致某行的仓库色号变化时,那一行才会重建。存的是 `Int?` 色号而不是解析后的 `Color`,
+是因为 `Color` 在这个部署目标(macOS 13)上不是稳定可比较的值,色号是纯数据、比较可靠。
+
+### 引用行菜单补齐 Copy Branch Name 与 Tracking Branch
+
+`GitReferenceRowMenu.entries(...)` 新增两个动作:本地分支和远程分支都加 `copyBranchName`
+(标签不加),本地分支加 `trackingBranch`。视图侧:`copyBranchName` 直接把
+`reference.shortName` 写进 `NSPasteboard`;`trackingBranch` 展开成一个子菜单,数据来自
+活动仓库的远程引用(`feature.gitReferences.filter { $0.kind == .remote }`),已配置上游时
+先显示当前上游和一个“Stop Tracking Branch”清除项,没有远程分支时显示禁用的占位项。
+
+写操作复用 Rust Core 已经支持的 `setUpstream` / `unsetUpstream`(见
+`rust/lithe-core/src/git/mod.rs`,Windows 端 `git-branches-api.ts` 已在用),macOS 侧只补
+接线:`GitOperations` 协议 + `RustGitOperations` 实现(`setUpstream(branch:to:)` 把上游
+引用和分支名传给 Core),`GitService`、`GitFeatureModel.setUpstream` /
+`unsetUpstream` 依次透传。**没有改动 Rust Core**,也没有新增 Core 命令。
+
+### 引用行的刷新比较必须包含远程分支
+
+`GitReferenceRowView` 以 `.equatable()` 渲染,只有 `==` 判不相等的行才会重建。Tracking Branch
+子菜单读的是活动仓库的远程分支列表,所以这份列表必须参与相等判断:否则一次只增删远程分支的
+`refs` 刷新(本地分支、选中和执行状态都没变)不会让行失效,子菜单会继续用建行时捕获的旧列表
+——远程分支已删还列着,或明明有远程分支却仍显示 “No Remote Branches”。
+
+原来 `remoteBranches` 放在 `GitReferenceRowActions` 里,而 `==` 刻意忽略这个结构(它的闭包每次
+body 都会重建,比较它会让所有行每帧都重建)。现在把它从 `actions` 移出,成为
+`GitReferenceRowView` 自己的值属性;同时把整条相等契约抽成 `GitReferenceRowRenderKey`
+(定义在 `GitReferenceRows.swift`),`==` 只比较这个 key。**新增任何被行或菜单读取的字段,都要
+加进 key,否则改了对 UI 没有影响。**
+
+非活动仓库分组的行是只读的、只触发选中,菜单不出现,所以它们传空列表,避免无谓的行重建。
+
+### 提交右键的重置支持 soft / mixed / hard
+
+`GitFeatureModel.resetCurrentBranch(to:)` 以前写死 `--mixed`,现在加
+`mode: GitResetMode = .mixed` 参数(`GitResetMode` 定义在 `GitModels.swift`,`argument`
+返回 `--soft` / `--mixed` / `--hard`)。默认值保持 `.mixed`,老调用点行为不变。
+
+提交右键原来是一个“Reset Current Branch to Here…”项,现在改成同名的**子菜单**,
+列出 soft / mixed / hard 三项,对应 Windows 的 `resetToCommit(repoPath, revision, mode)`。
+三档都走既有的确认对话框;hard 的确认按钮标记为 `destructive`(`GitCommitOperationKind`
+新增 `isDestructive`),并把确认文案改成“丢弃工作区更改”,确保硬重置需要用户明确确认。
+`GitGraphRowActions.onReset` 的签名相应变成 `(GitCommit, GitResetMode) -> Void`。
+
## 考虑过的备选方案
- **在 Core 增加“聚合所有仓库引用”的新命令**:被否。把呈现层的仓库身份引入稳定契约,还要
@@ -119,6 +188,13 @@ rename / delete)都只针对一个活动仓库,闭包直接从活动 `gitRep
“先隐式切仓库”会让一次点击产生用户没预期的活动仓库变更,失败时更难回滚;只读更简单也更安全。
- **给写操作闭包传入目标 `repositoryRoot`**:被否。写操作链路上游(对话框、待处理请求)都以活动
仓库为上下文,逐个改签名会把仓库身份扩散到调用链各处;在菜单层直接不提供更集中。
+- **按“当前可见仓库列表”的序号给仓库上色**:被否。隐藏工作树会让剩余仓库的色号整体前移,
+ 同一行的颜色会漂移;必须用 `availableRepositoryRoots`(过滤前完整列表)定位。
+- **在 Core 新增一个 macOS 专用的设置上游命令**:被否。Core 的 `setUpstream` / `unsetUpstream`
+ 已经存在且被 Windows 使用,macOS 只需接线。
+- **在引用行里保存解析后的 `Color` 而不是色号**:被否。部署目标是 macOS 13,`SwiftUI.Color`
+ 在那里不是可靠可比较的值,放进 `GitReferenceRowView.==` 会导致刷新判断不稳定;存 `Int?`
+ 色号更可靠,也顺带让单仓库的“无颜色”状态可表达。
## 后果
@@ -130,6 +206,10 @@ rename / delete)都只针对一个活动仓库,闭包直接从活动 `gitRep
- 只在可见仓库数 `> 1` 时走分组渲染;开关关闭时被隐藏的是工作树仓库,活动仓库始终保留。
- 引用面板的 Local / Remote / Tags 展开状态在所有仓库间共享(与 Windows 一致),同名分组
(如 `feature`)的折叠状态在不同仓库间也是共享的——这是对 Windows 行为的对齐,非缺陷。
+- 多仓库时每条引用行多一条 2.5px 色条、每个仓库节点多一个色点;单仓库工作区没有这些视觉元素。
+ 颜色取自固定 8 色,超过 8 个仓库时按位置回绕,因此两个相距 8 个位置的仓库会同色。
+- 引用行菜单新增 Copy Branch Name(本地 / 远程)和 Tracking Branch 子菜单;提交右键的重置
+ 从单项变成 soft / mixed / hard 子菜单。这些项都复用现有写操作,不增加新的 Core 命令。
## 验证
@@ -146,25 +226,49 @@ rename / delete)都只针对一个活动仓库,闭包直接从活动 `gitRep
旧刷新返回后不能覆盖新页)。
- `macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift` 的 `Git reference row menu` 套件
校验菜单策略:只读行返回空条目,活动行的本地 / 远程 / 标签菜单项与启用状态符合预期,
- 分支操作进行中时写操作项被禁用。
+ 分支操作进行中时写操作项被禁用,并新增 Copy Branch Name(本地 / 远程有、标签无)与
+ Tracking Branch(仅本地有)两条策略断言。
+- `macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift` 校验行的刷新契约:只增 / 只删
+ 远程分支时 `GitReferenceRowRenderKey` 必须判不相等(保证子菜单重建),远程分支列表不变时
+ 判相等(保证不白重建),行自身字段变化时仍然判不相等。
+- `macos/Tests/LitheTests/GitRepositoryColorTests.swift` 校验配色:8 色互不相同且不透明,
+ N 个仓库拿到互不相同的色号,隐藏中间仓库时后续仓库色号不变(证明按完整仓库列表定色),
+ 超过 8 个仓库回绕,单仓库不启用颜色,未知仓库回退到 0 号色。
+- `macos/Tests/LitheTests/ContextMenuCoverageTests.swift` 更新提交右键断言:重置现在是子菜单,
+ 可执行 mixed / hard 两项并回调到 `onReset(commit, mode)`。
+- `macos/Tests/LitheTests/AppLocalizationTests.swift` 的英 / 中对照校验扩展到新增的
+ Copy Branch Name、Tracking Branch、Stop Tracking Branch、No Remote Branches 与
+ soft / mixed / hard 三档重置的标题和确认文案。
+- 上游写操作复用 Core 已有的 `setUpstream` / `unsetUpstream`(`rust/lithe-core` 内已有测试
+ `git_write` 覆盖),macOS 侧只做接线,不新增 Rust 改动。
- git 控件本地化词条(含新增的两个开关文案)纳入
`macos/Tests/LitheTests/AppLocalizationTests.swift` 的英 / 中对照校验。
- 完整 macOS 编译与测试需要在 macOS 上运行:`./scripts/test-macos.sh`。
- `./scripts/verify-service-boundaries.sh`。
- **限制**:本次改动没有 macOS 工具链可用,Swift 代码和 UI 均未在本机编译或手工验证;
- 上述 Swift 测试由 macOS CI 执行。UI(仓库分组、工作树开关、跨仓库选中)未手工验证。
+ 上述 Swift 测试由 macOS CI 执行。UI(仓库分组、工作树开关、跨仓库选中、仓库配色、新增
+ 菜单项)未手工验证。
## 适用范围
- `macos/Sources/LitheGitModule/Models/GitModels.swift`
- `macos/Sources/LitheGitModule/Application/GitFeatureModel.swift`
+- `macos/Sources/LitheGitModule/Services/GitService.swift`
+- `macos/Sources/Lithe/Core/Rust/RustGitOperations.swift`
- `macos/Sources/Lithe/Views/Git/GitLogView.swift`
- `macos/Sources/Lithe/Views/Git/GitReferenceRows.swift`
+- `macos/Sources/Lithe/Views/Git/GitGraphView.swift`
+- `macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift`
- `macos/Resources/en.lproj/Localizable.strings`
- `macos/Resources/zh-Hans.lproj/Localizable.strings`
- `macos/Tests/LitheGitModuleTests/GitModuleTests.swift`
-- `macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift`
+- `macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift`
+- `macos/Tests/LitheTests/GitRepositoryColorTests.swift`
+- `macos/Tests/LitheTests/ContextMenuCoverageTests.swift`
- `macos/Tests/LitheTests/AppLocalizationTests.swift`
+上游写操作复用 `rust/lithe-core/src/git/mod.rs` 已有的 `setUpstream` / `unsetUpstream`,
+本笔记不改变这些命令的入参或契约。
+
不改变 `git.references`、`git.historyPage`、`workspace.repositories` 的 JSON 契约;不改提交图,
也不改顶部 `BranchSwitcherPopover`(仍为单仓库)。
diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv
index 38add1855..b60a21b45 100644
--- a/docs/development/platform-parity-matrix.csv
+++ b/docs/development/platform-parity-matrix.csv
@@ -16,6 +16,7 @@ git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实
git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git
git-rebase-stash,版本控制,Git,Rebase 与 Stash,已实现,待验证,已实现,待验证,Git,执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git
git-worktrees,版本控制,Git,Worktree 管理,已实现,待验证,已实现,待验证,Git,列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git
+git-multi-repository-references,版本控制,Git,多仓库引用面板:分组、配色与引用操作,已实现,待验证,已实现,待验证,Git,在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git
github-prs,协作,GitHub,Pull Request 列表与详情,已实现,待验证,已实现,待验证,GitHub,使用测试仓库验证 PR 列表、筛选、详情、分支比较和浏览器跳转。,,macos/Sources/Lithe/Views/GitHub; macos/Sources/Lithe/Services/GitHub,windows/tauri/src/features/github; shared/contracts/application-boundary.md
github-reviews-comments,协作,GitHub,Review 与评论,已实现,待验证,已实现,待验证,GitHub,创建、查看和回复 Review/评论,确认权限错误和网络失败不会丢失草稿。,,macos/Sources/Lithe/Views/GitHub,windows/tauri/src/features/github
ai-commit-generation,AI,AI 提交信息,Provider 配置与提交信息生成,已实现,待验证,已实现,待验证,Git / AI,用相同 diff、规则和 Provider 配置比较请求计划、取消、错误和生成文本。,,macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift; macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/git/services/ai-commit-service.ts; shared/contracts/ai-commit.md
diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md
index 4c28d1650..69ea0a859 100644
--- a/docs/development/platform-parity-matrix.md
+++ b/docs/development/platform-parity-matrix.md
@@ -4,9 +4,9 @@
- 最后复核:2026-09-24
- 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。)
-- 功能项:81
-- macOS:实现:✅ 69 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 72 待验证,— 9 不适用
-- Windows:实现:✅ 75 已实现,🟡 4 部分实现,❌ 2 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 79 待验证,— 2 不适用
+- 功能项:82
+- macOS:实现:✅ 70 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 73 待验证,— 9 不适用
+- Windows:实现:✅ 76 已实现,🟡 4 部分实现,❌ 2 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 80 待验证,— 2 不适用
## 实现状态定义
@@ -76,7 +76,7 @@
-版本控制 · 6 个能力点
+版本控制 · 7 个能力点
| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 |
| --- | --- | --- | --- | --- | --- | --- |
@@ -86,6 +86,7 @@
| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致。 | |
| Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | |
| Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | |
+| Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | |
diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings
index 911a08301..8ba06481f 100644
--- a/macos/Resources/en.lproj/Localizable.strings
+++ b/macos/Resources/en.lproj/Localizable.strings
@@ -501,10 +501,14 @@
"Reset current branch?" = "Reset current branch?";
"Cherry-pick" = "Cherry-pick";
"Revert" = "Revert";
+"Reset (Soft)" = "Reset (Soft)";
"Reset (Mixed)" = "Reset (Mixed)";
+"Reset (Hard)" = "Reset (Hard)";
"Apply %@ to the current branch." = "Apply %@ to the current branch.";
"Create a new commit that reverses %@." = "Create a new commit that reverses %@.";
+"Move the current branch to %@ and keep changes staged." = "Move the current branch to %@ and keep changes staged.";
"Move the current branch to %@ and keep changes unstaged." = "Move the current branch to %@ and keep changes unstaged.";
+"Move the current branch to %@ and discard all working-tree changes." = "Move the current branch to %@ and discard all working-tree changes.";
"Delete branch?" = "Delete branch?";
"Merge branch?" = "Merge branch?";
"Rebase branch?" = "Rebase branch?";
@@ -680,6 +684,7 @@
"Compare current branch with working tree" = "Compare current branch with working tree";
"Continue" = "Continue";
"Copy" = "Copy";
+"Copy Branch Name" = "Copy Branch Name";
"Copy Path / Reference" = "Copy Path / Reference";
"Copy Relative Path" = "Copy Relative Path";
"Delete Branch" = "Delete Branch";
@@ -702,6 +707,7 @@
"Hide Git tool window" = "Hide Git tool window";
"Hide commit decorations" = "Hide commit decorations";
"Highlight words" = "Highlight words";
+"Hard Reset (Discard Changes)" = "Hard Reset (Discard Changes)";
"Later" = "Later";
"Lithe Shelves" = "Lithe Shelves";
"Loading branches…" = "Loading branches…";
@@ -715,6 +721,7 @@
"Me" = "Me";
"Merge" = "Merge";
"Merge into Current Branch" = "Merge into Current Branch";
+"Mixed Reset (Keep Changes Unstaged)" = "Mixed Reset (Keep Changes Unstaged)";
"New Branch" = "New Branch";
"New Branch from '%@'…" = "New Branch from '%@'…";
"New Branch…" = "New Branch…";
@@ -724,6 +731,7 @@
"No differences" = "No differences";
"No matching branches" = "No matching branches";
"No matching users" = "No matching users";
+"No Remote Branches" = "No Remote Branches";
"No saved changes" = "No saved changes";
"No textual diff available" = "No textual diff available";
"OK" = "OK";
@@ -772,6 +780,7 @@
"Smart Checkout (Shelve)" = "Smart Checkout (Shelve)";
"Smart Checkout shelves your changes in Lithe, switches branch, then restores them. Force Checkout switches and discards them." = "Smart Checkout shelves your changes in Lithe, switches branch, then restores them. Force Checkout switches and discards them.";
"Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them." = "Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them.";
+"Soft Reset (Keep Changes Staged)" = "Soft Reset (Keep Changes Staged)";
"Stage File" = "Stage File";
"Stage all changes" = "Stage all changes";
"Stage file" = "Stage file";
@@ -780,6 +789,7 @@
"Stash or shelf changes here to switch branches safely." = "Stash or shelf changes here to switch branches safely.";
"Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it." = "Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it.";
"Step %lld of %lld" = "Step %lld of %lld";
+"Stop Tracking Branch" = "Stop Tracking Branch";
"Tag or revision" = "Tag or revision";
"Tags" = "Tags";
"The repository will be opened in detached HEAD state." = "The repository will be opened in detached HEAD state.";
@@ -788,6 +798,7 @@
"This sends the selected local branch to its configured remote." = "This sends the selected local branch to its configured remote.";
"Title" = "Title";
"Toggle preview" = "Toggle preview";
+"Tracking Branch" = "Tracking Branch";
"Uncommitted changes block this cherry-pick" = "Uncommitted changes block this cherry-pick";
"Uncommitted changes block this merge" = "Uncommitted changes block this merge";
"Uncommitted changes block this rebase" = "Uncommitted changes block this rebase";
diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings
index 905b7d4c4..c8dfec1e9 100644
--- a/macos/Resources/zh-Hans.lproj/Localizable.strings
+++ b/macos/Resources/zh-Hans.lproj/Localizable.strings
@@ -363,6 +363,7 @@
"Local changes would be overwritten" = "本地更改将被覆盖";
"Your changes to these files conflict with '%@':" = "你对这些文件的更改与“%@”冲突:";
"Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them." = "智能检出会先贮藏你的更改,切换分支后再恢复。强制检出会直接切换并丢弃这些更改。";
+"Soft Reset (Keep Changes Staged)" = "软重置(保留暂存更改)";
"Smart Checkout" = "智能检出";
"Force Checkout" = "强制检出";
"Merge in progress" = "正在合并";
@@ -374,6 +375,7 @@
"Continue Cherry-pick" = "继续拣选";
"Continue Revert" = "继续还原";
"Step %lld of %lld" = "第 %lld 步,共 %lld 步";
+"Stop Tracking Branch" = "停止跟踪分支";
"Resolve %lld conflicted file(s), stage them, then continue." = "请解决 %lld 个冲突文件并暂存,然后继续。";
"All conflicts resolved. Continue to finish, or abort to undo." = "冲突已全部解决。继续以完成操作,或中止以撤销。";
"Skip Commit" = "跳过该提交";
@@ -397,6 +399,7 @@
"Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it." = "贮藏会先把这些更改放到一边,执行操作后再恢复。如果操作因冲突中断,这些更改会一直保留在贮藏中,直到你完成该操作。";
"Stash and Continue" = "贮藏并继续";
"Merge into Current Branch" = "合并到当前分支";
+"Mixed Reset (Keep Changes Unstaged)" = "混合重置(保留未暂存更改)";
"Delete Branch" = "删除分支";
"No commits match this view" = "没有提交匹配当前视图";
"Commit details" = "提交详情";
@@ -719,6 +722,7 @@
"Previous difference" = "上一个差异";
"Next difference" = "下一个差异";
"Highlight words" = "高亮单词";
+"Hard Reset (Discard Changes)" = "硬重置(丢弃更改)";
"Parent version" = "父版本";
"Commit version" = "提交版本";
"This removes the stash from Git and cannot be undone." = "这会从 Git 中删除 Stash,且无法撤销。";
@@ -809,6 +813,7 @@
"Me" = "我";
"Search users" = "搜索用户";
"No matching users" = "没有匹配的用户";
+"No Remote Branches" = "没有远程分支";
"Create" = "创建";
"Create from '%@'." = "从“%@”创建。";
"Checkout branch after creation" = "创建后检出分支";
@@ -845,6 +850,7 @@
"Compare changes" = "比较更改";
"Show file history" = "显示文件历史";
"Toggle preview" = "切换预览";
+"Tracking Branch" = "跟踪的分支";
"New Branch from '%@'…" = "从“%@”新建分支…";
"Show Diff with Working Tree" = "显示与工作区的 Diff";
"Update" = "更新";
@@ -1113,6 +1119,7 @@
"Jump to latest" = "跳转到最新位置";
"Settings…" = "设置…";
"Copy" = "复制";
+"Copy Branch Name" = "复制分支名称";
"Host" = "主机";
"JDWP port" = "JDWP 端口";
"Local JDK Home (optional)" = "本地 JDK 主目录(可选)";
@@ -1713,10 +1720,14 @@
"Reset current branch?" = "重置当前分支?";
"Cherry-pick" = "拣选";
"Revert" = "反向提交";
+"Reset (Soft)" = "软重置";
"Reset (Mixed)" = "混合重置";
+"Reset (Hard)" = "硬重置";
"Apply %@ to the current branch." = "将提交 %@ 应用到当前分支。";
"Create a new commit that reverses %@." = "创建一个新提交,撤销提交 %@ 的更改。";
+"Move the current branch to %@ and keep changes staged." = "将当前分支移动到 %@,并将更改保留为已暂存状态。";
"Move the current branch to %@ and keep changes unstaged." = "将当前分支移动到 %@,并将更改保留为未暂存状态。";
+"Move the current branch to %@ and discard all working-tree changes." = "将当前分支移动到 %@,并丢弃所有工作区更改。";
"Delete branch?" = "删除分支?";
"Merge branch?" = "合并分支?";
"Rebase branch?" = "变基分支?";
diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
index e08ac8e5a..0555655e3 100644
--- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
+++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
@@ -258,6 +258,14 @@ struct RustGitOperations: GitOperations, Sendable {
write(at: rootURL, operation: "renameBranch", gitReference: reference, name: name)
}
+ func setUpstream(branch: GitReference, to upstream: GitReference, at rootURL: URL) -> GitProcessResult? {
+ write(at: rootURL, operation: "setUpstream", gitReference: upstream, name: branch.shortName)
+ }
+
+ func unsetUpstream(branch: GitReference, at rootURL: URL) -> GitProcessResult? {
+ write(at: rootURL, operation: "unsetUpstream", name: branch.shortName)
+ }
+
func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? {
write(at: rootURL, operation: "deleteBranch", gitReference: reference)
}
diff --git a/macos/Sources/Lithe/Views/Git/GitGraphView.swift b/macos/Sources/Lithe/Views/Git/GitGraphView.swift
index 7e2de850e..1794ae8c8 100644
--- a/macos/Sources/Lithe/Views/Git/GitGraphView.swift
+++ b/macos/Sources/Lithe/Views/Git/GitGraphView.swift
@@ -10,7 +10,7 @@ struct GitGraphRowActions {
let onSelect: (GitCommit) -> Void
let onCherryPick: (GitCommit) -> Void
let onRevert: (GitCommit) -> Void
- let onReset: (GitCommit) -> Void
+ let onReset: (GitCommit, GitResetMode) -> Void
let onCreateTag: (GitCommit) -> Void
var onSelectWithModifiers: ((GitCommit, NSEvent.ModifierFlags) -> Void)? = nil
var onContextSelect: ((GitCommit) -> Void)? = nil
@@ -37,7 +37,11 @@ struct GitGraphRowActions {
.action("New Tag…") { onCreateTag(commit) },
.action("Cherry-pick Commit…") { onCherryPick(commit) },
.action("Revert Commit…") { onRevert(commit) },
- .action("Reset Current Branch to Here…") { onReset(commit) }
+ .submenu("Reset Current Branch to Here…", items: [
+ .action("Soft Reset (Keep Changes Staged)") { onReset(commit, .soft) },
+ .action("Mixed Reset (Keep Changes Unstaged)") { onReset(commit, .mixed) },
+ .action("Hard Reset (Discard Changes)", role: .destructive) { onReset(commit, .hard) }
+ ])
] + (additionalContextMenuItems?(commit) ?? [])
}
diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift
index d51f462a8..910aed0cc 100644
--- a/macos/Sources/Lithe/Views/Git/GitLogView.swift
+++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift
@@ -205,7 +205,10 @@ struct GitLogView: View {
titleVisibility: .visible
) {
if let operation = pendingCommitOperation {
- Button(LocalizedStringKey(operation.kind.actionTitle)) {
+ Button(
+ LocalizedStringKey(operation.kind.actionTitle),
+ role: operation.kind.isDestructive ? .destructive : nil
+ ) {
pendingCommitOperation = nil
Task {
switch operation.kind {
@@ -213,8 +216,8 @@ struct GitLogView: View {
await feature.cherryPick(operation.commit)
case .revert:
await feature.revert(operation.commit)
- case .reset:
- await feature.resetCurrentBranch(to: operation.commit)
+ case .reset(let mode):
+ await feature.resetCurrentBranch(to: operation.commit, mode: mode)
}
}
}
@@ -743,6 +746,8 @@ struct GitLogView: View {
rows: rows(for: kind),
currentReference: currentReference,
isActiveRepository: true,
+ repositoryColorIndex: nil,
+ remoteBranches: remoteBranches,
actions: referenceRowActions
)
}
@@ -754,6 +759,10 @@ struct GitLogView: View {
let collapseKey = "repository:" + repository.repositoryRoot.standardizedFileURL.path
let isCollapsed = collapsedRepositoryGroups.contains(collapseKey)
let actions = repoRowActions(for: repository.repositoryRoot, isActive: isActive)
+ // Only the active repository's rows can open the "Tracking Branch"
+ // submenu; a read-only row shows no menu, so it gets no remote list.
+ let rowRemoteBranches = isActive ? remoteBranches : []
+ let colorIndex = gitRepositoryColorIndex(for: repository.repositoryRoot)
return VStack(alignment: .leading, spacing: 2) {
Button {
if isCollapsed {
@@ -768,6 +777,11 @@ struct GitLogView: View {
.frame(width: 10)
LitheSystemIcon(systemImage: "shippingbox", size: 14)
.foregroundStyle(LitheTheme.secondaryText)
+ if let colorIndex {
+ Circle()
+ .fill(GitRepositoryColor.color(at: colorIndex))
+ .frame(width: 8, height: 8)
+ }
Text(repository.name)
.font(GitVisual.section)
.foregroundStyle(LitheTheme.primaryText)
@@ -793,6 +807,8 @@ struct GitLogView: View {
rows: repository.localRows,
currentReference: repository.currentReference,
isActiveRepository: isActive,
+ repositoryColorIndex: colorIndex,
+ remoteBranches: rowRemoteBranches,
actions: actions
)
referenceSection(
@@ -803,6 +819,8 @@ struct GitLogView: View {
rows: repository.remoteRows,
currentReference: repository.currentReference,
isActiveRepository: isActive,
+ repositoryColorIndex: colorIndex,
+ remoteBranches: rowRemoteBranches,
actions: actions
)
referenceSection(
@@ -813,6 +831,8 @@ struct GitLogView: View {
rows: repository.tagRows,
currentReference: repository.currentReference,
isActiveRepository: isActive,
+ repositoryColorIndex: colorIndex,
+ remoteBranches: rowRemoteBranches,
actions: actions
)
}
@@ -827,6 +847,8 @@ struct GitLogView: View {
rows: [GitReferenceRow],
currentReference: GitReference?,
isActiveRepository: Bool,
+ repositoryColorIndex: Int?,
+ remoteBranches: [GitReference],
actions: GitReferenceRowActions
) -> some View {
VStack(alignment: .leading, spacing: 1) {
@@ -862,6 +884,8 @@ struct GitLogView: View {
currentReferenceID: currentReference?.id,
comparisonSourceID: comparisonSourceReference?.id,
isReadOnly: !isActiveRepository,
+ repositoryColorIndex: repositoryColorIndex,
+ remoteBranches: remoteBranches,
actions: actions
)
.equatable()
@@ -914,6 +938,13 @@ struct GitLogView: View {
return actions
}
+ /// Remote branches offered by the reference rows' "Tracking Branch"
+ /// submenu. Passed down as a value so a `refs` refresh that only changes the
+ /// remote branches still rebuilds a row whose own branch is unchanged.
+ private var remoteBranches: [GitReference] {
+ feature.gitReferences.filter { $0.kind == .remote }
+ }
+
/// Rebuilt on each body pass, but every closure is stable in behavior, and
/// `GitReferenceRowView.==` ignores this struct so it cannot by itself cause
/// a row to re-render.
@@ -960,6 +991,19 @@ struct GitLogView: View {
push: { reference in
pendingPushReference = reference
},
+ copyBranchName: { reference in
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(reference.shortName, forType: .string)
+ },
+ setBranchUpstream: { reference, upstream in
+ Task {
+ if let upstream {
+ await feature.setUpstream(reference, to: upstream)
+ } else {
+ await feature.unsetUpstream(reference)
+ }
+ }
+ },
branchOperation: { kind, reference in
pendingBranchOperation = GitBranchOperationRequest(kind: kind, reference: reference)
}
@@ -1445,8 +1489,8 @@ struct GitLogView: View {
onRevert: { commit in
pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .revert, commit: commit)
},
- onReset: { commit in
- pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset, commit: commit)
+ onReset: { commit, mode in
+ pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset(mode), commit: commit)
},
onCreateTag: { commit in
tagDialogRequest = GitTagDialogRequest(commit: commit)
@@ -1919,6 +1963,15 @@ struct GitLogView: View {
== feature.gitRepositoryRoot?.standardizedFileURL
}
+ /// The repository's palette slot, or `nil` when the pane is not grouping
+ /// repositories. The slot always comes from the full ordered root list, so
+ /// hiding worktrees cannot shift the colors of the repositories still shown.
+ private func gitRepositoryColorIndex(for repositoryRoot: URL) -> Int? {
+ let repositoryRoots = feature.availableRepositoryRoots
+ guard GitRepositoryColor.isVisible(for: repositoryRoots) else { return nil }
+ return GitRepositoryColor.index(for: repositoryRoot, in: repositoryRoots)
+ }
+
private var hasWorktreeRepositories: Bool {
let roots = feature.availableRepositoryRoots
return roots.contains { GitRepositoryHierarchy.isLinkedWorktreeRepository($0, among: roots) }
@@ -2110,7 +2163,7 @@ enum GitLogDatePreset: String, CaseIterable, Identifiable, Hashable {
private enum GitCommitOperationKind {
case cherryPick
case revert
- case reset
+ case reset(GitResetMode)
var title: String {
switch self {
@@ -2124,18 +2177,31 @@ private enum GitCommitOperationKind {
switch self {
case .cherryPick: "Cherry-pick"
case .revert: "Revert"
- case .reset: "Reset (Mixed)"
+ case .reset(.soft): "Reset (Soft)"
+ case .reset(.mixed): "Reset (Mixed)"
+ case .reset(.hard): "Reset (Hard)"
}
}
+ /// A hard reset discards working-tree changes, so its confirmation button is
+ /// destructive. Soft and mixed keep the changes.
+ var isDestructive: Bool {
+ if case .reset(.hard) = self { return true }
+ return false
+ }
+
func message(for commit: GitCommit) -> LocalizedStringKey {
switch self {
case .cherryPick:
"Apply \(commit.shortHash) to the current branch."
case .revert:
"Create a new commit that reverses \(commit.shortHash)."
- case .reset:
+ case .reset(.soft):
+ "Move the current branch to \(commit.shortHash) and keep changes staged."
+ case .reset(.mixed):
"Move the current branch to \(commit.shortHash) and keep changes unstaged."
+ case .reset(.hard):
+ "Move the current branch to \(commit.shortHash) and discard all working-tree changes."
}
}
}
@@ -2144,7 +2210,7 @@ private struct GitCommitOperationRequest: Identifiable {
let kind: GitCommitOperationKind
let commit: GitCommit
- var id: String { "\(kind.title):\(commit.hash)" }
+ var id: String { "\(kind.actionTitle):\(commit.hash)" }
}
private enum GitBranchDialogKind {
@@ -2873,6 +2939,9 @@ private struct GitReferenceRowActions {
let checkout: (GitReference) -> Void
let updateCurrentBranch: (GitReference) -> Void
let push: (GitReference) -> Void
+ let copyBranchName: (GitReference) -> Void
+ /// Sets the local branch's tracking branch. A `nil` upstream clears it.
+ let setBranchUpstream: (GitReference, GitReference?) -> Void
let branchOperation: (GitBranchOperationKind, GitReference) -> Void
}
@@ -2887,15 +2956,34 @@ private struct GitReferenceRowView: View, Equatable {
/// context menu, so this participates in equality to force a re-render when
/// the active repository changes.
let isReadOnly: Bool
+ /// The repository's palette slot for the row's leading color bar, or `nil`
+ /// when colors are not shown (single-repository workspaces). The index, not
+ /// the resolved `Color`, participates in equality so the row refresh rule
+ /// stays value-based.
+ let repositoryColorIndex: Int?
+ /// Remote branches backing this row's "Tracking Branch" submenu. Kept here,
+ /// next to the other compared values, rather than inside `actions` — which
+ /// `==` ignores — so a refresh that only changes the remote branch list
+ /// still rebuilds the row and its context menu.
+ let remoteBranches: [GitReference]
let actions: GitReferenceRowActions
static func == (lhs: Self, rhs: Self) -> Bool {
- lhs.row == rhs.row
- && lhs.isSelected == rhs.isSelected
- && lhs.isPerformingBranchOperation == rhs.isPerformingBranchOperation
- && lhs.currentReferenceID == rhs.currentReferenceID
- && lhs.comparisonSourceID == rhs.comparisonSourceID
- && lhs.isReadOnly == rhs.isReadOnly
+ lhs.renderKey == rhs.renderKey
+ }
+
+ /// The values the row is compared on; see `GitReferenceRowRenderKey`.
+ private var renderKey: GitReferenceRowRenderKey {
+ GitReferenceRowRenderKey(
+ row: row,
+ isSelected: isSelected,
+ isPerformingBranchOperation: isPerformingBranchOperation,
+ currentReferenceID: currentReferenceID,
+ comparisonSourceID: comparisonSourceID,
+ isReadOnly: isReadOnly,
+ repositoryColorIndex: repositoryColorIndex,
+ remoteBranches: remoteBranches
+ )
}
var body: some View {
@@ -2956,6 +3044,13 @@ private struct GitReferenceRowView: View, Equatable {
.padding(.leading, CGFloat(row.depth * 16))
.padding(.trailing, 8)
.frame(maxWidth: .infinity, minHeight: 28, alignment: .leading)
+ .overlay(alignment: .leading) {
+ if let repositoryColorIndex {
+ Rectangle()
+ .fill(GitRepositoryColor.color(at: repositoryColorIndex))
+ .frame(width: 2.5)
+ }
+ }
.clipShape(RoundedRectangle(cornerRadius: 4))
.contentShape(Rectangle())
.litheRowHover(
@@ -3075,7 +3170,44 @@ private struct GitReferenceRowView: View, Equatable {
return .action("Rename…", role: role, isEnabled: isEnabled) {
actions.renameBranch(reference)
}
+ case .copyBranchName:
+ return .action("Copy Branch Name", role: role, isEnabled: isEnabled) {
+ actions.copyBranchName(reference)
+ }
+ case .trackingBranch:
+ return trackingBranchMenu(for: reference, isEnabled: isEnabled)
+ }
+ }
+
+ /// Lists the workspace's remote branches so the user can point a local
+ /// branch at one, plus a clear entry that only appears when the branch
+ /// already tracks something.
+ private func trackingBranchMenu(
+ for reference: GitReference,
+ isEnabled: Bool
+ ) -> LitheContextMenuItem {
+ var items: [LitheContextMenuItem] = []
+ if let upstream = reference.upstreamShortName {
+ items.append(.action(upstream, systemImage: "checkmark", isEnabled: false) {})
+ items.append(.action("Stop Tracking Branch", isEnabled: isEnabled) {
+ actions.setBranchUpstream(reference, nil)
+ })
+ items.append(.separator)
+ }
+ if remoteBranches.isEmpty {
+ items.append(.action("No Remote Branches", isEnabled: false) {})
+ } else {
+ for remote in remoteBranches {
+ items.append(.action(
+ remote.shortName,
+ systemImage: "network",
+ isEnabled: isEnabled && remote.shortName != reference.upstreamShortName
+ ) {
+ actions.setBranchUpstream(reference, remote)
+ })
+ }
}
+ return .submenu("Tracking Branch", systemImage: "network", items: items)
}
private func referenceIcon(_ reference: GitReference) -> String {
diff --git a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
index 73039e6d2..90ff06f23 100644
--- a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
+++ b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
@@ -91,6 +91,29 @@ enum GitReferenceRowsBuilder {
}
}
+/// Values that decide whether a rendered reference row must be rebuilt.
+///
+/// `GitReferenceRowView` compares itself with `==` so a `LazyVStack` row is
+/// only re-evaluated when something it displays changes. Everything the row —
+/// or its context menu — reads therefore has to be part of this key. The row's
+/// `actions` struct is deliberately absent: its closures are rebuilt on every
+/// body pass and are stable in behavior.
+struct GitReferenceRowRenderKey: Equatable {
+ let row: GitReferenceRow
+ let isSelected: Bool
+ let isPerformingBranchOperation: Bool
+ let currentReferenceID: String?
+ let comparisonSourceID: String?
+ let isReadOnly: Bool
+ /// Palette slot for the row's leading color bar; `nil` when colors are off.
+ let repositoryColorIndex: Int?
+ /// Remote branches backing the "Tracking Branch" submenu. A `refs` refresh
+ /// can add or remove remote branches while every other field of a local row
+ /// stays identical; without this the row keeps the menu it built earlier
+ /// and offers a stale — or still empty — remote branch list.
+ let remoteBranches: [GitReference]
+}
+
/// One entry of a reference row's context menu, stripped of closures and
/// localization so the menu's *policy* is testable without a SwiftUI host. The
/// view maps every entry to a `LitheContextMenuItem` and supplies the closure.
@@ -116,6 +139,8 @@ enum GitReferenceMenuAction: Equatable {
case push
case delete
case rename
+ case copyBranchName
+ case trackingBranch
}
/// Builds the context menu a reference row shows.
@@ -165,6 +190,8 @@ enum GitReferenceRowMenu {
entries.append(.separator)
entries.append(.action(.pullRebase, isEnabled: !isPerformingBranchOperation, isDestructive: false))
entries.append(.action(.pullMerge, isEnabled: !isPerformingBranchOperation, isDestructive: false))
+ entries.append(.separator)
+ entries.append(.action(.copyBranchName, isEnabled: true, isDestructive: false))
}
if kind == .local {
@@ -178,6 +205,9 @@ enum GitReferenceRowMenu {
entries.append(.separator)
entries.append(.action(.rename, isEnabled: !isPerformingBranchOperation, isDestructive: false))
+ entries.append(.action(.trackingBranch, isEnabled: !isPerformingBranchOperation, isDestructive: false))
+ entries.append(.separator)
+ entries.append(.action(.copyBranchName, isEnabled: true, isDestructive: false))
}
return entries
diff --git a/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift b/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift
new file mode 100644
index 000000000..adff3a02b
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift
@@ -0,0 +1,41 @@
+import Foundation
+import SwiftUI
+
+/// Per-repository accent colors for the Git Log reference pane.
+///
+/// IntelliJ IDEA marks every repository in the log's reference tree with a
+/// colored dot. Lithe mirrors that: the repository group header gets a dot and
+/// each reference row gets a thin leading bar. A color is assigned by the
+/// repository's position in the workspace's *full*, ordered repository list, so
+/// hiding worktrees never recolors the repositories that stay visible.
+enum GitRepositoryColor {
+ /// Eight hues chosen to stay legible on both light and dark backgrounds.
+ static let paletteHex = [
+ "#55d68b", "#65a9ff", "#d77eea", "#f3aa59",
+ "#e76c72", "#56c7cf", "#b08cff", "#ffd166"
+ ]
+
+ /// A workspace with a single repository has nothing to disambiguate, so the
+ /// pane stays exactly as it was before this feature.
+ static func isVisible(for availableRepositoryRoots: [URL]) -> Bool {
+ availableRepositoryRoots.count > 1
+ }
+
+ /// The palette index for `repositoryRoot`, matched by standardized path
+ /// against the full ordered list. Unknown roots fall back to index 0.
+ static func index(for repositoryRoot: URL, in availableRepositoryRoots: [URL]) -> Int {
+ let target = repositoryRoot.standardizedFileURL.path
+ guard let position = availableRepositoryRoots.firstIndex(where: {
+ $0.standardizedFileURL.path == target
+ }) else { return 0 }
+ return position % paletteHex.count
+ }
+
+ static func color(for repositoryRoot: URL, in availableRepositoryRoots: [URL]) -> Color {
+ color(at: index(for: repositoryRoot, in: availableRepositoryRoots))
+ }
+
+ static func color(at index: Int) -> Color {
+ Color(hex: paletteHex[((index % paletteHex.count) + paletteHex.count) % paletteHex.count])
+ }
+}
diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
index 3e480d556..25946b71d 100644
--- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
+++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
@@ -2615,6 +2615,38 @@ package final class GitFeatureModel: ObservableObject {
}
}
+ /// Points a local branch at a remote branch, mirroring the write operation
+ /// IntelliJ IDEA exposes as "Tracking Branch".
+ package func setUpstream(_ reference: GitReference, to upstream: GitReference) async {
+ guard let gitRepositoryRoot else { return }
+ isPerformingBranchOperation = true
+ let result = await withGitOperation {
+ await service.setUpstream(reference, to: upstream, at: gitRepositoryRoot)
+ }
+ isPerformingBranchOperation = false
+ if result.succeeded {
+ notify?("Tracking \(reference.shortName) with \(upstream.shortName)")
+ await refreshGit()
+ } else {
+ notify?(trimmedMessage(result))
+ }
+ }
+
+ package func unsetUpstream(_ reference: GitReference) async {
+ guard let gitRepositoryRoot else { return }
+ isPerformingBranchOperation = true
+ let result = await withGitOperation {
+ await service.unsetUpstream(reference, at: gitRepositoryRoot)
+ }
+ isPerformingBranchOperation = false
+ if result.succeeded {
+ notify?("Stopped tracking \(reference.shortName)")
+ await refreshGit()
+ } else {
+ notify?(trimmedMessage(result))
+ }
+ }
+
package func deleteBranch(_ reference: GitReference) async {
guard let gitRepositoryRoot else { return }
isPerformingBranchOperation = true
@@ -3436,14 +3468,14 @@ package final class GitFeatureModel: ObservableObject {
await startIntegration(.commit(commit), operation: .revert)
}
- package func resetCurrentBranch(to commit: GitCommit) async {
+ package func resetCurrentBranch(to commit: GitCommit, mode: GitResetMode = .mixed) async {
guard let gitRepositoryRoot else { return }
isPerformingBranchOperation = true
let result = await withGitOperation {
await service.resetCurrentBranch(
to: commit.hash,
at: gitRepositoryRoot,
- mode: "--mixed"
+ mode: mode.argument
)
}
isPerformingBranchOperation = false
diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift
index a0d7bfb97..9aba267ad 100644
--- a/macos/Sources/LitheGitModule/Models/GitModels.swift
+++ b/macos/Sources/LitheGitModule/Models/GitModels.swift
@@ -1295,6 +1295,20 @@ package enum GitPullStrategy: String, Sendable {
case rebase
}
+/// How far a reset moves HEAD, the index, and the working tree when the user
+/// resets the current branch to a commit.
+package enum GitResetMode: String, Sendable, CaseIterable {
+ /// Move HEAD only; the index and working tree keep their changes staged.
+ case soft
+ /// Move HEAD and the index; the working tree keeps its changes unstaged.
+ case mixed
+ /// Move HEAD, the index, and the working tree, discarding local changes.
+ case hard
+
+ /// The argument Rust Core parses in the `reset` write operation.
+ package var argument: String { "--\(rawValue)" }
+}
+
package enum GitOperationKind: String, Equatable, Sendable {
case merge
case rebase
diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift
index e2dad6f63..18bcff000 100644
--- a/macos/Sources/LitheGitModule/Services/GitService.swift
+++ b/macos/Sources/LitheGitModule/Services/GitService.swift
@@ -127,6 +127,8 @@ package protocol GitOperations: Sendable {
func repairWorktrees(at rootURL: URL) -> GitProcessResult?
func pruneWorktrees(at rootURL: URL) -> GitProcessResult?
func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult?
+ func setUpstream(branch: GitReference, to upstream: GitReference, at rootURL: URL) -> GitProcessResult?
+ func unsetUpstream(branch: GitReference, at rootURL: URL) -> GitProcessResult?
func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult?
func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult?
func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult?
@@ -207,6 +209,8 @@ package extension GitOperations {
return createWorktree(named: name, from: reference, revision: request.revision, at: request.destination, repositoryRoot: rootURL)
}
func createHistoryRecoveryBranch(named name: String, reference: String, at rootURL: URL) -> GitProcessResult? { nil }
+ func setUpstream(branch: GitReference, to upstream: GitReference, at rootURL: URL) -> GitProcessResult? { nil }
+ func unsetUpstream(branch: GitReference, at rootURL: URL) -> GitProcessResult? { nil }
func exportPatch(at rootURL: URL, source: GitPatchSource, paths: [String], base: String?, target: String?, metadataOnly: Bool) -> Result {
.failure(GitPatchFailure("Patch export is unavailable."))
}
@@ -923,6 +927,22 @@ package struct GitService: Sendable {
await command(at: repositoryRoot) { $0.renameBranch(reference, to: newName, at: repositoryRoot) }
}
+ func setUpstream(
+ _ reference: GitReference,
+ to upstream: GitReference,
+ at repositoryRoot: URL
+ ) async -> CommandResult {
+ await command(at: repositoryRoot) {
+ $0.setUpstream(branch: reference, to: upstream, at: repositoryRoot)
+ }
+ }
+
+ func unsetUpstream(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult {
+ await command(at: repositoryRoot) {
+ $0.unsetUpstream(branch: reference, at: repositoryRoot)
+ }
+ }
+
func deleteBranch(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult {
await command(at: repositoryRoot) { $0.deleteBranch(reference, at: repositoryRoot) }
}
diff --git a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift
index bfa168c1b..4dcf5c9f4 100644
--- a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift
+++ b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift
@@ -408,7 +408,7 @@ struct GitGraphPerformanceBaselineTests {
onSelect: { _ in },
onCherryPick: { _ in },
onRevert: { _ in },
- onReset: { _ in },
+ onReset: { _, _ in },
onCreateTag: { _ in }
)
let scrollView = GitGraphScrollView.makeScrollView(
@@ -472,7 +472,7 @@ struct GitGraphPerformanceBaselineTests {
onSelect: { _ in },
onCherryPick: { _ in },
onRevert: { _ in },
- onReset: { _ in },
+ onReset: { _, _ in },
onCreateTag: { _ in }
)
rowsView.update(
@@ -621,7 +621,7 @@ struct GitGraphPerformanceBaselineTests {
onSelect: { _ in },
onCherryPick: { _ in },
onRevert: { _ in },
- onReset: { _ in },
+ onReset: { _, _ in },
onCreateTag: { _ in }
)
let scrollView = GitGraphScrollView.makeScrollView(
diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift
index bd4d8d672..71e2052f6 100644
--- a/macos/Tests/LitheTests/AppLocalizationTests.swift
+++ b/macos/Tests/LitheTests/AppLocalizationTests.swift
@@ -151,6 +151,13 @@ struct AppLocalizationTests {
#expect(translations["Cherry-pick Commit…"] == "拣选提交…")
#expect(translations["Revert Commit…"] == "反向提交(保留历史)…")
#expect(translations["Reset Current Branch to Here…"] == "将当前分支重置到这里…")
+ #expect(translations["Copy Branch Name"] == "复制分支名称")
+ #expect(translations["Tracking Branch"] == "跟踪的分支")
+ #expect(translations["Stop Tracking Branch"] == "停止跟踪分支")
+ #expect(translations["No Remote Branches"] == "没有远程分支")
+ #expect(translations["Soft Reset (Keep Changes Staged)"] == "软重置(保留暂存更改)")
+ #expect(translations["Mixed Reset (Keep Changes Unstaged)"] == "混合重置(保留未暂存更改)")
+ #expect(translations["Hard Reset (Discard Changes)"] == "硬重置(丢弃更改)")
#expect(translations["Undo Commit…"] == "撤销最近一次提交(保留更改)…")
#expect(translations["Edit Commit Message…"] == "编辑提交消息…")
#expect(translations["Squash Commits…"] == "合并所选提交…")
@@ -283,8 +290,10 @@ struct GitLocalizationTests {
let keys = [
"Revert this commit?", "Revert", "Create a new commit that reverses %@.",
"Cherry-pick this commit?", "Apply %@ to the current branch.",
- "Reset current branch?", "Reset (Mixed)",
+ "Reset current branch?", "Reset (Soft)", "Reset (Mixed)", "Reset (Hard)",
+ "Move the current branch to %@ and keep changes staged.",
"Move the current branch to %@ and keep changes unstaged.",
+ "Move the current branch to %@ and discard all working-tree changes.",
"Delete branch?", "Merge branch?", "Rebase branch?",
"Checkout and rebase branch?", "Pull remote branch with rebase?",
"Pull remote branch with merge?", "New Tag", "Tag name",
@@ -294,7 +303,10 @@ struct GitLocalizationTests {
"Last 7 Days", "Last 30 Days", "Loading commits…", "Load more commits",
"Clear Git console", "Git exited with code %d", "%lld files",
"%lld worktrees", "Worktree action unavailable", "Invalid Git tag name.",
- "Show worktree repositories", "Hide worktree repositories"
+ "Show worktree repositories", "Hide worktree repositories",
+ "Copy Branch Name", "Tracking Branch", "Stop Tracking Branch", "No Remote Branches",
+ "Soft Reset (Keep Changes Staged)", "Mixed Reset (Keep Changes Unstaged)",
+ "Hard Reset (Discard Changes)"
]
let pattern = try NSRegularExpression(pattern: #"%(?:\d+\$)?(?:lld|ld|d|@)"#)
for key in keys {
diff --git a/macos/Tests/LitheTests/ContextMenuCoverageTests.swift b/macos/Tests/LitheTests/ContextMenuCoverageTests.swift
index 9531e7804..ad6a88fa3 100644
--- a/macos/Tests/LitheTests/ContextMenuCoverageTests.swift
+++ b/macos/Tests/LitheTests/ContextMenuCoverageTests.swift
@@ -60,13 +60,23 @@ struct ContextMenuCoverageTests {
onSelect: { _ in Issue.record("Right click must not check out or change selection") },
onCherryPick: { received.append("cherry:\($0.hash)") },
onRevert: { received.append("revert:\($0.hash)") },
- onReset: { received.append("reset:\($0.hash)") },
+ onReset: { commit, mode in received.append("reset:\(mode.rawValue):\(commit.hash)") },
onCreateTag: { received.append("tag:\($0.hash)") }
).contextMenuItems(for: commit)
- for title in ["New Tag…", "Cherry-pick Commit…", "Revert Commit…", "Reset Current Branch to Here…"] {
+ for title in ["New Tag…", "Cherry-pick Commit…", "Revert Commit…"] {
try #require(menu.first { $0.title == title }).action()
}
- #expect(received == ["tag:abcdef123456", "cherry:abcdef123456", "revert:abcdef123456", "reset:abcdef123456"])
+ let resetMenu = try #require(menu.first { $0.title == "Reset Current Branch to Here…" })
+ guard case .submenu(let resetItems) = resetMenu.kind else {
+ Issue.record("Reset should offer soft, mixed, and hard as a submenu")
+ return
+ }
+ try #require(resetItems.first { $0.title == "Mixed Reset (Keep Changes Unstaged)" }).action()
+ try #require(resetItems.first { $0.title == "Hard Reset (Discard Changes)" }).action()
+ #expect(received == [
+ "tag:abcdef123456", "cherry:abcdef123456", "revert:abcdef123456",
+ "reset:mixed:abcdef123456", "reset:hard:abcdef123456"
+ ])
}
@Test
diff --git a/macos/Tests/LitheTests/GitGraphInteractionTests.swift b/macos/Tests/LitheTests/GitGraphInteractionTests.swift
index 99b249663..624de76e2 100644
--- a/macos/Tests/LitheTests/GitGraphInteractionTests.swift
+++ b/macos/Tests/LitheTests/GitGraphInteractionTests.swift
@@ -381,7 +381,7 @@ struct GitGraphInteractionTests {
private func actions(_ select: @escaping (GitCommit) -> Void) -> GitGraphRowActions {
GitGraphRowActions(onSelect: select, onCherryPick: { _ in }, onRevert: { _ in },
- onReset: { _ in }, onCreateTag: { _ in })
+ onReset: { _, _ in }, onCreateTag: { _ in })
}
}
diff --git a/macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift b/macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift
new file mode 100644
index 000000000..7075f09b0
--- /dev/null
+++ b/macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift
@@ -0,0 +1,83 @@
+import Testing
+@testable import Lithe
+@testable import LitheGitModule
+
+/// `GitReferenceRowView` compares itself with `==` so a `LazyVStack` row is only
+/// re-evaluated when something it shows changes. Its "Tracking Branch" submenu
+/// reads the workspace's remote branches, so a `refs` refresh that only adds or
+/// drops remote branches has to invalidate the row too; otherwise the row keeps
+/// the menu it built earlier and offers a remote branch list that no longer
+/// exists — or keeps saying "No Remote Branches" after one appeared.
+@Suite("Git reference row render key")
+struct GitReferenceRowRenderKeyTests {
+ private func localBranch(_ shortName: String) -> GitReference {
+ GitReference(
+ fullName: "refs/heads/\(shortName)",
+ shortName: shortName,
+ kind: .local,
+ isCurrent: false,
+ upstreamShortName: nil
+ )
+ }
+
+ private func remoteBranch(_ shortName: String) -> GitReference {
+ GitReference(
+ fullName: "refs/remotes/\(shortName)",
+ shortName: shortName,
+ kind: .remote,
+ isCurrent: false,
+ upstreamShortName: nil
+ )
+ }
+
+ /// A row whose own branch and every other compared field are held constant
+ /// except the ones a test varies explicitly.
+ private func key(
+ remoteBranches: [GitReference],
+ isSelected: Bool = false
+ ) -> GitReferenceRowRenderKey {
+ GitReferenceRowRenderKey(
+ row: GitReferenceRow(
+ id: "reference:feature/x",
+ name: "feature/x",
+ depth: 0,
+ content: .reference(localBranch("feature/x"))
+ ),
+ isSelected: isSelected,
+ isPerformingBranchOperation: false,
+ currentReferenceID: "refs/heads/main",
+ comparisonSourceID: nil,
+ isReadOnly: false,
+ repositoryColorIndex: nil,
+ remoteBranches: remoteBranches
+ )
+ }
+
+ @Test("a refresh that only gains a remote branch invalidates the row")
+ func gainingARemoteBranchInvalidatesTheRow() {
+ #expect(key(remoteBranches: []) != key(remoteBranches: [remoteBranch("origin/main")]))
+ }
+
+ @Test("a refresh that only drops a remote branch invalidates the row")
+ func droppingARemoteBranchInvalidatesTheRow() {
+ #expect(
+ key(remoteBranches: [remoteBranch("origin/main"), remoteBranch("origin/release")])
+ != key(remoteBranches: [remoteBranch("origin/main")])
+ )
+ }
+
+ @Test("an unchanged remote branch list keeps the row equal")
+ func stableRemoteBranchListKeepsTheRowEqual() {
+ #expect(
+ key(remoteBranches: [remoteBranch("origin/main")])
+ == key(remoteBranches: [remoteBranch("origin/main")])
+ )
+ }
+
+ @Test("a changed row still invalidates the row with a stable remote branch list")
+ func changedRowStillInvalidatesTheRow() {
+ let remoteBranches = [remoteBranch("origin/main")]
+
+ #expect(key(remoteBranches: remoteBranches) != key(remoteBranches: remoteBranches, isSelected: true))
+ }
+}
diff --git a/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift b/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift
index 574d93d01..f39b06c9c 100644
--- a/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift
+++ b/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift
@@ -208,10 +208,11 @@ struct GitReferenceRowMenuTests {
guard case .action(let action, let isEnabled, _) = entry else { continue }
switch action {
case .checkout, .checkoutAndRebase, .merge, .rebase,
- .pullRebase, .pullMerge, .update, .push, .delete, .rename:
+ .pullRebase, .pullMerge, .update, .push, .delete, .rename,
+ .trackingBranch:
#expect(!isEnabled)
case .newBranch, .showDiffWithWorkingTree, .compareWithCurrent,
- .compareWithSelectedSource, .selectForCompare:
+ .compareWithSelectedSource, .selectForCompare, .copyBranchName:
break
}
}
@@ -224,4 +225,19 @@ struct GitReferenceRowMenuTests {
#expect(result.contains(.action(.compareWithSelectedSource, isEnabled: true, isDestructive: false)))
#expect(!result.contains(.action(.selectForCompare, isEnabled: true, isDestructive: false)))
}
+
+ @Test
+ func localAndRemoteRowsOfferCopyBranchNameButTagsDoNot() {
+ for kind in [GitReferenceKind.local, .remote] {
+ #expect(entries(kind: kind).contains(.action(.copyBranchName, isEnabled: true, isDestructive: false)))
+ }
+ #expect(!entries(kind: .tag).contains(.action(.copyBranchName, isEnabled: true, isDestructive: false)))
+ }
+
+ @Test
+ func onlyLocalRowsOfferTrackingBranch() {
+ #expect(entries(kind: .local).contains(.action(.trackingBranch, isEnabled: true, isDestructive: false)))
+ #expect(!entries(kind: .remote).contains(.action(.trackingBranch, isEnabled: true, isDestructive: false)))
+ #expect(!entries(kind: .tag).contains(.action(.trackingBranch, isEnabled: true, isDestructive: false)))
+ }
}
diff --git a/macos/Tests/LitheTests/GitRepositoryColorTests.swift b/macos/Tests/LitheTests/GitRepositoryColorTests.swift
new file mode 100644
index 000000000..f7a881e93
--- /dev/null
+++ b/macos/Tests/LitheTests/GitRepositoryColorTests.swift
@@ -0,0 +1,89 @@
+import AppKit
+import Foundation
+import SwiftUI
+import Testing
+@testable import Lithe
+
+/// The Git Log marks each repository with a color. The color must stay with a
+/// repository when worktrees are hidden, or the pane would recolor itself on a
+/// toggle and mislead the user about which repository a row belongs to.
+@Suite("Git repository colors")
+struct GitRepositoryColorTests {
+ private func roots(_ count: Int) -> [URL] {
+ (0.. outer {
+ #expect(colors[outer] != colors[inner])
+ }
+ }
+ }
+
+ @Test
+ func colorForRootMatchesItsSlot() throws {
+ let repositoryRoots = roots(3)
+ for (slot, root) in repositoryRoots.enumerated() {
+ let resolved = try #require(
+ NSColor(GitRepositoryColor.color(for: root, in: repositoryRoots)).usingColorSpace(.sRGB)
+ )
+ let expected = try #require(NSColor(GitRepositoryColor.color(at: slot)).usingColorSpace(.sRGB))
+ #expect(resolved == expected)
+ }
+ }
+
+ @Test
+ func repositoriesReceiveDistinctSlotsInListOrder() {
+ let repositoryRoots = roots(8)
+ let slots = repositoryRoots.map {
+ GitRepositoryColor.index(for: $0, in: repositoryRoots)
+ }
+ #expect(slots == Array(0..<8))
+ }
+
+ @Test
+ func slotIsStableWhenTheVisibleSubsetChanges() {
+ let all = roots(3)
+ // Hiding the middle repository (for example a worktree) must not shift
+ // the third repository onto the second slot. Assignment always reads the
+ // full ordered list, so `repo-2` keeps slot 2.
+ #expect(GitRepositoryColor.index(for: all[2], in: all) == 2)
+ #expect(GitRepositoryColor.index(for: all[2], in: [all[0], all[2]]) == 1)
+ }
+
+ @Test
+ func slotsWrapAfterThePaletteSize() {
+ let repositoryRoots = roots(9)
+ #expect(GitRepositoryColor.index(for: repositoryRoots[8], in: repositoryRoots) == 0)
+ }
+
+ @Test
+ func singleRepositoryWorkspaceShowsNoColor() {
+ #expect(!GitRepositoryColor.isVisible(for: roots(1)))
+ #expect(GitRepositoryColor.isVisible(for: roots(2)))
+ // The fallback color still resolves, so a caller that ignores visibility
+ // cannot crash; it simply gets the first palette entry.
+ #expect(GitRepositoryColor.index(for: roots(1)[0], in: roots(1)) == 0)
+ }
+
+ @Test
+ func unknownRootFallsBackToTheFirstColor() {
+ let repositoryRoots = roots(3)
+ let outsider = URL(fileURLWithPath: "/elsewhere/other")
+ #expect(GitRepositoryColor.index(for: outsider, in: repositoryRoots) == 0)
+ }
+}
diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json
index c281a619a..0b91af80e 100644
--- a/shared/platform-feature-matrix.json
+++ b/shared/platform-feature-matrix.json
@@ -442,6 +442,28 @@
"owner": "Git",
"verification": "列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。"
},
+ {
+ "id": "git-multi-repository-references",
+ "area": "版本控制",
+ "group": "Git",
+ "capability": "多仓库引用面板:分组、配色与引用操作",
+ "macos": {
+ "evidence": [
+ "macos/Sources/Lithe/Views/Git"
+ ],
+ "implementationStatus": "implemented",
+ "verificationStatus": "pending"
+ },
+ "windows": {
+ "evidence": [
+ "windows/tauri/src/features/git"
+ ],
+ "implementationStatus": "implemented",
+ "verificationStatus": "pending"
+ },
+ "owner": "Git",
+ "verification": "在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。"
+ },
{
"id": "github-prs",
"area": "协作",