Go Sidecar Repository 并发锁改造:让并发请求安全地进入 Git 仓库层

本文为山东大学软件学院创新实训项目博客

Go Sidecar Repository 并发锁改造:让并发请求安全地进入 Git 仓库层

上一篇博客里,我记录了 IntelliGit Go Sidecar 主循环并发化的过程。

那次改动解决的是一个很直接的性能问题:sidecar/cmd/sidecar/main.go 原来读到请求以后,会同步执行 router.Dispatch(req),导致所有请求都在 Go 端排队。后来我把每个请求的实际处理放进 goroutine,并加了最大并发数限制,这样 remote.fetch 这种慢请求就不会天然堵住后面的 staging.statuscommit.logdiff.workdir

但并发化不是故事的结尾。它更像是把一条窄路拓宽了。请求终于可以同时进入 Sidecar,可是这些请求最后都会走到同一个地方:

text 复制代码
handler
  -> internal/git.Repository
  -> go-git backend / Git CLI backend
  -> 当前打开的 Git 仓库

如果多个 goroutine 同时操作同一个 Repository,新的问题就出现了:底层仓库状态是否允许并发读写?go-gitWorktreeRepositoryStorer 以及系统 Git CLI 对同一个 .git 目录的操作,是否能在没有任何协调的情况下安全交错?

这次修复的目标就是修复计划里的第 2 项:

text 复制代码
修改 sidecar/internal/git/repository.go(添加并发锁)

简单说,就是给 Repository 这个 facade 加一把读写锁,让所有 Git 能力继续从同一个稳定入口出去,但入口内部开始有明确的并发秩序。


一、为什么主循环并发化以后必须补 Repository 锁

在主循环并发化之前,Sidecar 的实际执行模型是串行的:

text 复制代码
请求 A: staging.status
请求 B: remote.fetch
请求 C: diff.workdir

实际执行:
A 完成 -> B 完成 -> C 完成

这种模型虽然慢,但有一个"顺手得到"的特性:同一时刻通常只有一个请求真正进入 Git 层。即使 Repository 自己没有锁,也不容易暴露并发访问问题。

主循环改成 goroutine 后,模型变成了:

text 复制代码
请求 A: staging.status   -> goroutine 1
请求 B: remote.fetch     -> goroutine 2
请求 C: diff.workdir     -> goroutine 3

这时候,多个请求可能同时调用同一个 Repository

text 复制代码
goroutine 1 -> repo.Status()
goroutine 2 -> repo.Fetch()
goroutine 3 -> repo.DiffWorkdir("src/main.ts")

如果不加保护,风险主要有三类。

第一类是读写交错。比如 Status() 正在读取 worktree 和 index,另一边 Add()Restore() 正在修改 index 或工作区,读出来的状态可能处在一个中间态。

第二类是写写交错。比如 Checkout()Pull()ResetToCommit() 这类操作都可能改变 HEAD、refs、index、worktree。如果它们和另一个写操作同时发生,结果就很难推理。

第三类是 go-git 和 Git CLI 混用时的边界问题。IntelliGit 的 Git 层不是只用 go-git,也不是只用系统 Git。它是一个 facade:

text 复制代码
Repository
  -> goGitBackend
  -> gitCliBackend

例如 Pull() 先尝试 go-git fast-forward,如果遇到 non-fast-forward,再 fallback 到 Git CLI 做本地 merge。ApplyPatch()UnstageHunk()MergeContinue() 等能力也走 CLI。也就是说,真正需要保护的不是某一个 backend,而是当前仓库这个共享资源。

所以锁最合适的位置不是 handler,也不是 goGitBackendgitCliBackend 内部,而是 Repository facade。


二、Repository facade 本来就是最自然的并发边界

这次改动的目标文件是:

text 复制代码
sidecar/internal/git/repository.go

这个文件在 Sidecar Git 层里承担的是门面职责。handler 层只依赖它,不直接关心某个能力到底是 go-git 实现,还是 Git CLI 实现。

原来的结构大致是:

go 复制代码
type Repository struct {
    path  string
    goGit *goGitBackend
    cli   *gitCliBackend
}

然后暴露一批稳定方法:

go 复制代码
func (r *Repository) Status() ([]FileStatus, error) {
    return r.goGit.Status()
}

func (r *Repository) Add(path string) error {
    return r.goGit.Add(path)
}

func (r *Repository) Pull(remoteName string, auth *AuthMethod, progress io.Writer) error {
    branchRef, err := r.goGit.PullFastForward(remoteName, auth, progress)
    if err == nil {
        return nil
    }
    if !errors.Is(err, gogit.ErrNonFastForwardUpdate) {
        return err
    }

    remoteRef := fmt.Sprintf("%s/%s", remoteName, branchRef.Short())
    return r.cli.RunLocalMerge(progress, remoteRef)
}

可以看到,Repository 是所有 Git 操作必经的入口。handler 不会绕过它去直接调 backend。

这正好符合加锁的原则:

text 复制代码
谁拥有共享资源的统一入口,谁负责定义并发访问规则。

如果把锁加到 handler 层,每个 handler 都要记得自己该拿读锁还是写锁,规则会散掉。如果把锁加到 backend 层,又没法统一协调 go-git 和 Git CLI 的组合操作,比如 Pull() 这种"一半 go-git,一半 CLI"的流程。

所以这次我只改 repository.go,让它成为仓库级并发控制的唯一关口。


三、为什么使用 sync.RWMutex

这次没有直接用普通的 sync.Mutex,而是用了:

go 复制代码
sync.RWMutex

原因很简单:IntelliGit 里大量高频请求其实是读操作。

例如:

text 复制代码
repo.head
repo.isClean
staging.status
commit.log
branch.list
branch.current
remote.list
diff.workdir
diff.staged
merge.status

这些操作会读取仓库状态,但不应该主动改变工作区、index、refs 或 remote 配置。它们之间可以并发执行。

而写操作包括:

text 复制代码
staging.add
staging.remove
staging.restore
staging.applyPatch
commit.create
branch.create
branch.delete
branch.checkout
remote.add
remote.setUrl
remote.remove
remote.fetch
remote.pull
merge.abort
merge.continue
commit.reset
commit.checkout

这些操作会改变仓库状态,必须独占。

如果用普通 Mutex,所有读操作也会被完全串行化。那主循环并发化带来的收益会被削弱很多。比如 commit.logbranch.list 本来可以一起读,现在就没必要互相等待。

因此更合适的模型是:

text 复制代码
读 + 读:允许并发
读 + 写:互斥
写 + 写:互斥

这正是 sync.RWMutex 的语义。


四、第一步:给 Repository 增加锁字段

实际代码改动的第一步,是增加 sync import:

go 复制代码
import (
    "errors"
    "fmt"
    "io"
    "sync"

    gogit "github.com/go-git/go-git/v5"
    "github.com/go-git/go-git/v5/plumbing"
)

然后在 Repository 结构体里增加一把锁:

go 复制代码
type Repository struct {
    path  string
    goGit *goGitBackend
    cli   *gitCliBackend
    mu    sync.RWMutex
}

这个改动看起来很小,但语义很关键。

它表示从现在开始,Repository 不再只是一个简单的转发器。它仍然是 facade,但它多承担了一个职责:

text 复制代码
管理当前仓库的并发访问秩序。

这里没有改变 newRepository() 的创建逻辑,因为 sync.RWMutex 的零值就是可用状态:

go 复制代码
func newRepository(path string, repo *gogit.Repository) *Repository {
    return &Repository{
        path:  path,
        goGit: newGoGitBackend(repo),
        cli:   newGitCliBackend(path),
    }
}

也就是说,不需要额外初始化 mu


五、读操作:用 RLock 保护共享读取

接下来是给读方法加 RLock()

最简单的例子是 Status()

go 复制代码
func (r *Repository) Status() ([]FileStatus, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    return r.goGit.Status()
}

这表示多个 Status() 可以并发执行,Status()Log()Branches() 这类读操作也可以并发执行。但只要有写操作进来,写操作会等待当前读操作结束;写操作执行期间,新的读操作也要等。

类似地,提交历史读取也加了读锁:

go 复制代码
func (r *Repository) Log(max int) ([]CommitInfo, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    return r.goGit.Log(max)
}

分支读取也一样:

go 复制代码
func (r *Repository) Branches() ([]BranchInfo, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    return r.goGit.Branches()
}

func (r *Repository) CurrentBranch() (string, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    return r.goGit.CurrentBranch()
}

Diff 读取也用读锁:

go 复制代码
func (r *Repository) DiffWorkdir(filePath string) (*PatchDetail, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    return r.goGit.DiffWorkdir(filePath)
}

func (r *Repository) DiffStaged(filePath string) (*PatchDetail, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    return r.goGit.DiffStaged(filePath)
}

这里有一个容易犹豫的点:DiffWorkdir() 虽然只是读,但它会读取 worktree、index 和 blob 内容,耗时可能比较长。那它会不会把写操作挡住?

会的。

但这是正确的代价。因为当 diff 正在基于某个文件状态生成 patch 时,如果另一边同时 Checkout()Restore()ResetToCommit(),得到的 patch 就可能混入不一致状态。相比"让 diff 和写操作完全自由交错",让写操作等当前读完成是更稳的选择。


六、写操作:用 Lock 独占修改仓库状态

所有会改变仓库状态的方法都改成写锁。

暂存文件是典型写操作:

go 复制代码
func (r *Repository) Add(path string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.goGit.Add(path)
}

func (r *Repository) Remove(path string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.goGit.Remove(path)
}

func (r *Repository) Restore(path string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.goGit.Restore(path)
}

提交也是写操作:

go 复制代码
func (r *Repository) Commit(message, authorName, authorEmail string) (string, error) {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.goGit.Commit(message, authorName, authorEmail)
}

分支创建、删除、切换也都是写操作:

go 复制代码
func (r *Repository) CreateBranch(name string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.goGit.CreateBranch(name)
}

func (r *Repository) Checkout(branch string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.goGit.Checkout(branch)
}

远程配置修改也一样:

go 复制代码
func (r *Repository) AddRemote(name, url string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.goGit.AddRemote(name, url)
}

func (r *Repository) SetRemoteURL(name, url string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.goGit.SetRemoteURL(name, url)
}

这些方法本身没有复杂逻辑,真正重要的是分类。只要一个方法会修改 index、worktree、refs、config 或 merge 状态,就不能和其他仓库操作随意并发。


七、Pull 是这次最需要注意的方法

这次加锁时,我最关注的是 Pull()

因为它不是单纯转发给某一个 backend,而是一个组合流程:

go 复制代码
func (r *Repository) Pull(remoteName string, auth *AuthMethod, progress io.Writer) error {
    branchRef, err := r.goGit.PullFastForward(remoteName, auth, progress)
    if err == nil {
        return nil
    }
    if !errors.Is(err, gogit.ErrNonFastForwardUpdate) {
        return err
    }

    remoteRef := fmt.Sprintf("%s/%s", remoteName, branchRef.Short())
    return r.cli.RunLocalMerge(progress, remoteRef)
}

它的语义是:

text 复制代码
先尝试 go-git fast-forward pull
如果可以快进,直接结束
如果不能快进,fallback 到系统 Git 做本地 merge

这里必须让整个流程在同一把写锁里面完成:

go 复制代码
func (r *Repository) Pull(remoteName string, auth *AuthMethod, progress io.Writer) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    branchRef, err := r.goGit.PullFastForward(remoteName, auth, progress)
    if err == nil {
        return nil
    }
    if !errors.Is(err, gogit.ErrNonFastForwardUpdate) {
        return err
    }

    remoteRef := fmt.Sprintf("%s/%s", remoteName, branchRef.Short())
    return r.cli.RunLocalMerge(progress, remoteRef)
}

如果只锁 PullFastForward(),不锁后面的 RunLocalMerge(),中间就会出现一个空窗:

text 复制代码
goroutine A: PullFastForward 发现不能快进
goroutine A: 准备 fallback merge
goroutine B: Checkout / Reset / Commit 插进来
goroutine A: 继续 RunLocalMerge

这会让 Pull() 的语义变得不完整。用户发起的是一个 pull 操作,内部不管是 fast-forward 还是 merge fallback,都应该被看成同一次仓库写操作。

所以这次的原则是:

text 复制代码
一个 Repository 方法代表一个业务级 Git 操作。
只要它是写操作,锁要覆盖整个方法,而不是只覆盖其中某几行。

八、CLI patch 和 merge 操作也必须纳入同一把锁

IntelliGit 里有一些能力天然更适合走系统 Git CLI,比如:

text 复制代码
staging.applyPatch
staging.unstageHunk
staging.discardHunk
merge.abort
merge.continue
diff.workdirRaw
diff.stagedRaw
logAllRaw

这里也不能因为它们不是 go-git 调用,就放在锁外面。

例如 patch 相关操作都会改 index 或工作区:

go 复制代码
func (r *Repository) ApplyPatch(patchContent string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.cli.ApplyPatch(patchContent)
}

func (r *Repository) UnstageHunk(patchContent string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.cli.UnstageHunk(patchContent)
}

func (r *Repository) DiscardHunk(patchContent string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.cli.DiscardHunk(patchContent)
}

merge 状态读取是读锁:

go 复制代码
func (r *Repository) MergeStatus() (*MergeStatusResult, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    return r.cli.MergeStatus()
}

但 abort 和 continue 是写锁:

go 复制代码
func (r *Repository) MergeAbort() error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.cli.MergeAbort()
}

func (r *Repository) MergeContinue(message string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    return r.cli.MergeContinue(message)
}

这个分类很重要。因为从 Repository 的角度看,不管底层是 go-git 还是 Git CLI,它们操作的是同一个 .git 目录和同一个工作区。

也就是说,锁保护的对象不是某个 Go 对象本身,而是"当前仓库状态"这个更大的资源。


九、Push 为什么暂时用读锁

这次我把 Push() 包成了读锁:

go 复制代码
func (r *Repository) Push(remoteName string, auth *AuthMethod, progress io.Writer) error {
    r.mu.RLock()
    defer r.mu.RUnlock()

    return r.goGit.Push(remoteName, auth, progress)
}

这可能看起来有一点微妙。因为 push 是远程操作,听起来像"写"。但它主要写的是远端仓库,本地仓库通常只是读取当前 refs、对象和认证信息,然后把对象发送出去。

从本地共享资源保护的角度看,它不像 Fetch() 那样会更新本地 remote refs,也不像 Pull() 那样会改变 worktree 或 HEAD。因此这次先把它归为本地读操作。

不过这里也留下一个后续可以继续评估的点:如果后面发现 go-git 的 Push() 会修改本地某些状态,或者我们给 push 增加了本地状态写入,比如记录 push metadata、刷新本地 refs,那么它也应该升级为写锁。

这次的分类标准是:

text 复制代码
以当前代码对本地仓库状态的影响为准。

十、完整分类结果

这次改完后,Repository 里的方法大致分成两类。

读锁方法:

text 复制代码
Path
Head
IsClean
Status
Log
LogFrom
GetCommit
Branches
RemoteBranches
CurrentBranch
AheadBehind
Remotes
Push
MergeStatus
DiffWorkdir
DiffStaged
DiffWorkdirRaw
DiffStagedRaw
DiffCommits
DiffWithParent
GetCommitPatch
FileContentAtCommit
ListFilesAtCommit
LogAll
LogAllRaw

写锁方法:

text 复制代码
Add
AddAll
AddGlob
Remove
Restore
ApplyPatch
UnstageHunk
DiscardHunk
Commit
CreateBranch
DeleteBranch
Checkout
CheckoutNewBranch
AddRemote
SetRemoteURL
RemoveRemote
Fetch
Pull
MergeAbort
MergeContinue
ResetToCommit
CheckoutCommit

这里的重点不是"每个函数前面多了两行代码",而是 Git 层终于有了清晰的并发契约:

text 复制代码
查询类操作可以一起跑。
修改类操作必须独占仓库。
查询和修改不能交错在同一个仓库状态上。

这让前面主循环并发化后的风险被收回到一个明确边界里。


十一、这次改动没有改变 handler 和前端协议

这次修复的一个好处是,它完全发生在 Sidecar 内部。

前端仍然这样发请求:

text 复制代码
window.electronAPI.invokeGit("staging.status")
window.electronAPI.invokeGit("remote.fetch")
window.electronAPI.invokeGit("diff.workdir")

Electron Main 仍然只负责把请求转发给 Sidecar。

handler 仍然只调用:

go 复制代码
ctx.Repo.Status()
ctx.Repo.Fetch(...)
ctx.Repo.DiffWorkdir(...)

真正变化的是 Repository 内部多了一层并发控制。

这也符合之前 Sidecar 重构时确定的边界:handler 不关心某个能力走 go-git 还是 Git CLI,也不应该关心锁策略。handler 要表达的是"我要执行某个 Git 命令";Repository 要负责的是"这个命令在当前仓库里怎样安全执行"。


十二、验证过程

改动完成后,我先执行了格式化:

bash 复制代码
gofmt -w sidecar/internal/git/repository.go

然后运行 Sidecar 端测试:

bash 复制代码
cd sidecar
go test ./...

第一次在沙箱内运行时,Go 构建缓存目录被当前环境拦住了:

text 复制代码
open C:\Users\pc23\AppData\Local\go-build\...\*.d: Access is denied

这不是代码编译错误,而是测试过程需要访问用户级 go-build cache。按规则提升权限后重新运行,测试通过:

text 复制代码
?    intelligit-sidecar/cmd/sidecar       [no test files]
ok   intelligit-sidecar/internal/git      10.141s
ok   intelligit-sidecar/internal/handler  2.862s
?    intelligit-sidecar/internal/protocol [no test files]

最终本次实际修改的代码文件是:

text 复制代码
sidecar/internal/git/repository.go

新增内容包括:

text 复制代码
1. 引入 sync 包。
2. 在 Repository 上新增 mu sync.RWMutex。
3. 所有只读 facade 方法使用 RLock/RUnlock。
4. 所有会改变本地仓库状态的方法使用 Lock/Unlock。
5. Pull 的 go-git fast-forward 和 CLI merge fallback 被同一把写锁完整包住。
6. Git CLI 的 patch、merge、raw diff、raw log 能力也统一通过 Repository 锁进入。

十三、这次改动和上一篇并发化的关系

如果只看这次代码,可能会觉得它只是给每个方法加锁,没什么复杂逻辑。但放回整个修复计划里,它的位置其实很关键。

上一篇主循环并发化解决的是:

text 复制代码
请求不要在 Sidecar 入口处排队。

这次 Repository 加锁解决的是:

text 复制代码
请求并发进入 Git 层以后,不要把同一个仓库状态读写乱。

它们是一前一后的关系:

text 复制代码
main.go 并发化
  -> 提高请求调度吞吐
  -> 多个请求可能同时进入 Repository

repository.go 加锁
  -> 明确仓库读写规则
  -> 让并发请求在 Git 层有秩序地执行

如果只有主循环并发,没有 Repository 锁,那么性能问题可能缓解了,但数据安全问题会被放大。

如果只有 Repository 锁,没有主循环并发,那么锁虽然存在,但请求仍然在入口处串行排队,读写锁也发挥不出应有价值。

所以这两步要连在一起看。第一步让 Sidecar 有能力并发处理请求,第二步让这种并发不会冲破 Git 仓库层的安全边界。


十四、总结

这次改动不改变 UI,不改变 IPC 协议,也不改变 handler 的调用方式。它只是在 Repository 这个最合适的位置补上了一层仓库级并发控制。

最终得到的模型是:

text 复制代码
Sidecar 主循环:
  多个请求可以进入 goroutine 并发处理

Repository facade:
  多个读请求可以同时读取仓库
  写请求独占仓库
  读写请求互斥

go-git / Git CLI backend:
  继续专注各自的 Git 能力实现

这一步的价值不只是避免潜在 race。更重要的是,它把"并发之后仓库层应该如何工作"这件事写进了代码结构里。

从此以后,新增 Git 能力时也有了一个非常清楚的判断标准:

text 复制代码
如果只是读取当前仓库状态,用 RLock。
如果会改变 index、worktree、refs、config、merge 状态或本地 remote refs,用 Lock。
如果一个方法内部组合了 go-git 和 Git CLI,锁要覆盖整个业务操作。

这就是这次 repository.go 并发锁改造的完整记录。

相关推荐
小雨青年11 小时前
GitHub Actions 工作流性能优化实战,先看瓶颈,再改缓存和并发
git
祁白_11 小时前
PHP无参读取文件与RCE总结
安全·php·writeup·总结·rce
无风听海11 小时前
Cookie 深度技术指南:从原理到安全实践
安全
2501_9318037511 小时前
Go 接口学习笔记:从语法到心法
笔记·学习·golang
汤愈韬11 小时前
IP安全 SEC VPN_2
网络·网络协议·安全·网络安全·security
pixcarp11 小时前
Redis ZSet:底层设计与实践
数据库·redis·后端·学习·golang·web
0x000712 小时前
Git Bash 中无法启动 Claude Code ?
开发语言·git·bash
2401_8685347812 小时前
My Experience in the Computer Room
安全
stsdddd12 小时前
【YOLO安防防护场景安全帽-安全背心目标检测数据集】
安全·yolo·目标检测