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

IntelliGit 项目结构检查:当功能跑通以后,结构债不可忽略

这次做的不是一个具体 Bug 修复,也不是新增某个功能,而是对 IntelliGit 当前项目结构做了一次比较完整的体检。

在前面的开发中,IntelliGit 已经陆续打通了很多关键链路:Electron 主进程、React 前端、Go Sidecar、Git 状态读取、提交、Push / Pull、分支切换、Diff 展示、仓库配置等等。功能越堆越多以后,一个很自然的问题也开始出现:项目能跑,但代码是不是还好维护?

这次我看的重点不是“哪里语法错了”,而是下面几类问题:

项目结构是否清晰
单文件是否过长
组件和状态是否耦合过高
前后端协议边界是否明确
后续继续加功能时,修改成本会不会越来越高

最后的结论比较明确:项目目前还能正常通过类型检查和 Go 测试,但已经出现了典型的“功能先跑通,结构后补课”的阶段性问题。尤其是前端的 MainApp.tsxuseAppStore.ts 和全局 CSS,已经开始承担太多职责;后端 Go Sidecar 的 handler 层也有类似趋势。

这篇博客就记录一下这次结构体检的过程,以及我对 IntelliGit 后续重构方向的一些判断。


一、先确认项目现在是什么形态

项目整体是一个典型的桌面端混合架构:

Electron Main Process
  -> 负责窗口、IPC、Sidecar 进程管理

Preload
  -> 通过 contextBridge 暴露安全 API 给 Renderer

React Renderer
  -> IntelliGit 的主要界面

Go Sidecar
  -> 负责真正的 Git 操作

从目录上看,核心代码主要分布在两个地方:

src/
  main/       Electron 主进程
  preload/    预加载脚本
  renderer/   React 前端
  shared/     前后端共享类型

sidecar/
  cmd/        Go 程序入口
  internal/   Git、handler、protocol 等内部模块

这个大方向是合理的。Electron 负责桌面壳,React 负责界面,Go 负责 Git 能力,三者职责在架构层面是分开的。

真正的问题不在“项目选型不对”,而是在每一层内部,很多东西还没有继续细分。也就是说,第一层边界是清楚的,但第二层、第三层边界开始变得模糊。


二、最直观的问题:几个文件已经明显过长

我先做了一次文件行数统计。结果非常醒目:

src/renderer/src/assets/main.css              1669 行
src/renderer/src/assets/features.css          1308 行
src/renderer/src/MainApp.tsx                  1261 行
src/renderer/src/store/useAppStore.ts          946 行
sidecar/internal/handler/handlers.go           817 行
sidecar/internal/git/diff.go                   549 行

单文件长不一定就是坏事。有些文件虽然长,但如果职责高度内聚,比如纯数据表、协议定义、测试用例集合,也可以接受。

但这几个文件的问题在于:它们不是“一个职责写得比较完整”,而是“多个职责暂时住在同一个文件里”。

比如 MainApp.tsx 里面同时包含:

主题 token 配置
仓库侧栏
顶部工具栏
变更视图
Diff 视图
历史视图
设置页
通知栏
状态栏
自动刷新逻辑
大量弹窗和表单交互

这就说明它已经不只是一个入口组件,而是整个前端工作台的集合体。后续任何人要改历史视图、设置页、仓库弹窗、主题、状态栏,都要进入同一个大文件里。

这类文件最麻烦的地方不是“行数多看着累”,而是它会让修改边界变得不清楚。一个看似局部的小改动,很容易影响同文件里的其他逻辑。


三、前端组件目录已经规划了,但实现还没有跟上

项目里其实已经有 componentsviews 两个目录:

src/renderer/src/components/
src/renderer/src/views/

这两个目录里都有 README,说明当时已经意识到前端应该按组件和视图拆分。

components/README.md 里规划的是基础 UI 组件,例如:

Button
Input
StatusBadge
DiffViewer
Terminal

views/README.md 里规划的是业务视图,例如:

Workspace
History
Settings
BranchGraph

但实际情况是,这些目录目前只有 README,没有真正承载业务代码。正式界面的大部分内容仍然集中在 MainApp.tsx 里。

这就形成了一个很有意思的状态:项目“知道自己应该怎么长”,但代码还没有真正长到那个结构里。

我觉得这也是很多项目从原型走向产品时会遇到的节点。早期为了快,先把功能写在一个文件里很正常;但当功能已经验证过以后,就应该把已经稳定下来的区域拆出去,让目录结构重新反映业务结构。


四、useAppStore.ts 已经变成了第二个主程序

前端另一个核心问题是 Zustand store 太大。

useAppStore.ts 现在有 946 行,里面不仅保存状态,还包含大量业务操作:

配置读取和保存
仓库添加、创建、克隆、切换
远程仓库探测
文件状态刷新
提交历史刷新
分支列表刷新
Push / Pull
Commit
Diff 读取
Hunk 暂存
Commit Graph
Checkout Commit
Reset Commit
错误和成功消息

这已经不是普通意义上的 store,而是一个混合体:

Store
  + Git service
  + Config service
  + IPC adapter
  + UI message manager
  + Repository state machine

短期看,这种写法很方便。组件只要 useAppStore(),就能拿到所有状态和动作。

但长期看,它会带来两个问题。

第一个问题是状态边界不清楚。配置状态、仓库状态、Git 工作区状态、Diff 临时状态、UI 消息状态都在一起,任何逻辑都可以直接读写任意部分。

第二个问题是组件订阅会变粗。MainApp.tsx 里很多组件直接调用:

const { repos, currentRepo, switchRepo, addRepo, createRepo, cloneRepo } = useAppStore()

或者直接:

const { workdirDiff, selectedFilePath } = useAppStore()

如果没有 selector,状态变化很容易带来过大的重渲染范围。当前项目规模还不算特别大,所以问题不一定明显;但如果 Diff、历史图、AI 分析、沙箱测试这些功能继续加进去,store 会越来越像一个没有边界的全局变量。

比较理想的方向是把它拆成几层:

api/gitClient.ts
  -> 只负责调用 window.electronAPI.invokeGit

services/repositoryService.ts
  -> 负责仓库配置、远程探测、认证 payload 组装

store/repositoryStore.ts
  -> 当前仓库、仓库列表

store/gitStatusStore.ts
  -> 文件状态、分支、ahead/behind

store/diffStore.ts
  -> 当前文件 diff、hunk 操作

store/uiStore.ts
  -> 视图、通知、loading

这样前端状态会更像几个小工作台,而不是所有工具都堆在同一个抽屉里。


五、CSS 的问题不是多,而是两套时代叠在一起

前端样式文件也比较典型。

现在入口里同时引入了:

import './assets/main.css'
import './assets/features.css'

其中 main.css 有 1669 行,features.css 有 1308 行。

我看下来发现,main.css 里既有早期测试面板的 .app-* 样式,也有正式工作台的 .ig-* 样式;features.css 里又继续定义和覆盖大量 .ig-*

这说明样式系统现在不是按组件或视图组织的,而是按开发阶段自然堆叠出来的。

早期测试面板还在:

.app-container
.app-header
.dashboard-grid
.status-card
.history-list

正式界面也在:

.ig-app
.ig-toolbar
.ig-changes-view
.ig-history-view
.ig-settings-view
.ig-statusbar

问题不是“CSS 行数多”,而是两个阶段的样式共享同一个全局命名空间。全局 CSS 最大的风险就是隐式覆盖:你改一个类名附近的规则,以为只影响一个区域,实际上可能会影响另一个视图。

如果后续拆组件,我认为 CSS 也应该跟着拆:

MainApp.tsx
MainApp.css

views/ChangesView/index.tsx
views/ChangesView/styles.css

views/HistoryView/index.tsx
views/HistoryView/styles.css

components/DiffViewer/index.tsx
components/DiffViewer/styles.css

不一定马上引入 CSS Modules,但至少应该让样式文件和组件边界一致。这样以后删掉测试面板时,也能很清楚地知道哪些样式可以一起删除。


六、App.tsx 暴露了一个真实的 lint 问题

这次我还跑了一下检查:

npm.cmd run typecheck
go test ./...
npm.cmd run lint

结果是:

TypeScript typecheck 通过
Go test ./... 通过
ESLint 未通过

ESLint 的 6 个错误全部来自 App.tsx 的 hooks 调用顺序。

现在 App.tsx 的逻辑大致是:

function App() {
  const mode = window.electronAPI.mode

  if (mode !== 'test') {
    return <MainApp />
  }

  const [command, setCommand] = useState('')
  const [payloadStr, setPayloadStr] = useState('{}')
  const { loading, history, error, executeCommand, clearHistory } = useGitStore()

  ...
}

也就是说,如果不是 test 模式,函数会提前 return;如果是 test 模式,才继续调用 hooks。

React 的规则要求 hooks 在每次 render 中调用顺序必须一致。虽然 mode 在运行过程中大概率不会变,但从代码规则上看,这仍然是条件 hooks。

这个问题也从侧面说明:正式入口和测试面板最好拆开。

更清晰的写法应该是:

App.tsx
  -> 只判断 mode
  -> 渲染 MainApp 或 TestPanel

TestPanel.tsx
  -> 自己内部使用 useGitStore / useKeyboardShortcut

这样正式界面和测试界面互不干扰,lint 也能回到干净状态。


七、Sidecar 的 handler 层也开始变成“大文件入口”

前端不是唯一的问题。Go Sidecar 里也有一个明显的大文件:

sidecar/internal/handler/handlers.go  817 行

这个文件里现在放了很多类别的 handler:

repo.open
repo.init
repo.clone

staging.status
staging.add
staging.remove
staging.applyPatch

commit.create
commit.log
commit.reset
commit.checkoutCommit

branch.list
branch.checkout
branch.aheadBehind

remote.fetch
remote.pull
remote.push

merge.status
merge.abort
merge.continue

diff.workdir
diff.commits
diff.withParent

这和前端 MainApp.tsx 的问题很像:所有东西都能在一个文件里找到,短期很方便;但随着命令越来越多,文件会变得越来越像“命令仓库”。

比较好的拆法是按命令域分文件:

repo_handlers.go
staging_handlers.go
commit_handlers.go
branch_handlers.go
remote_handlers.go
merge_handlers.go
diff_handlers.go

registry.go 继续负责集中注册命令,这个设计可以保留。只是具体实现不应该全部挤在一个文件里。

这样做还有一个好处:当后续某个人只负责 merge 冲突 UI 对接时,他只需要重点看 merge_handlers.goremote.go,而不必在 800 多行 handler 文件里上下滚动。


八、Go Git 层的问题:go-git 和系统 git CLI 混用,需要一个明确边界

Sidecar 的 Git 层现在主要基于 go-git,但并不是完全只用 go-git。

比如普通的 status、commit、branch 操作大量使用 go-git;但 hunk 暂存、原始 diff、merge 继续/中止、部分 log 能力又会调用系统 git CLI:

exec.Command("git", "apply", "--cached", "--unidiff-zero", "-")
exec.Command("git", "-C", r.path, "merge", "--no-edit", ref)
exec.Command("git", "-C", r.path, "diff", "--name-only", "--diff-filter=U")

这本身并不是错误。前面做 hunk 暂存和 merge 工作流时已经证明,go-git 在某些细粒度场景下能力有限,借助系统 Git 是现实选择。

但现在的问题是:混用策略还没有被抽象成清晰边界。

理想情况下,Git 层应该明确区分:

GoGitBackend
  -> 使用 go-git 完成对象模型、分支、提交、状态等能力

GitCliBackend
  -> 使用系统 git 完成 hunk、merge、raw diff 等能力

Repository
  -> 对上层暴露稳定方法,不让 handler 关心底层到底是 go-git 还是 CLI

这样后续如果某个命令从 go-git 改成 CLI,或者从 CLI 改回 go-git,上层 handler 和前端都不需要跟着动。

现在虽然大部分 CLI 调用仍然封装在 internal/git 包里,但环境变量、错误包装、行为差异还比较分散。尤其是认证失败、merge 冲突、非交互式环境这些细节,最好集中成一个 Git CLI adapter。


九、前后端协议现在太“自由”

前端调用 Sidecar 的入口是:

invokeGit: (command: string, payload?: Record<string, unknown>) => Promise<SidecarResponse>

这个接口非常灵活,任何命令字符串都可以传,任何 payload 都可以传,返回值也是 unknown

灵活的代价就是类型约束不够。比如前端 store 里会出现很多这样的代码:

set({ branches: (branchRes.data as BranchInfo[]) || [] })

const data = currentRes.data as { branch: string }

set({ workdirDiff: response.data as PatchDetail })

这些 as 其实是在告诉 TypeScript:“我知道它是什么,你先相信我。”

但如果 Go 端某个命令返回结构变了,或者命令名打错了,TypeScript 很难提前发现。

我觉得后续可以考虑建立一个命令表:

type GitCommandMap = {
  'repo.open': {
    payload: { path: string }
    result: { path: string }
  }
  'staging.status': {
    payload: undefined
    result: FileStatusInfo[]
  }
  'branch.current': {
    payload: undefined
    result: { branch: string }
  }
}

然后把 invokeGit 包一层:

invokeGit<K extends keyof GitCommandMap>(
  command: K,
  payload: GitCommandMap[K]['payload']
): Promise<GitCommandMap[K]['result']>

这样前端和后端之间至少在 TypeScript 侧有一张清晰的协议地图。它不能完全替代 Go 端类型校验,但能显著减少前端随手 as 的数量。


十、自动刷新策略也需要重新思考

当前正式界面里有自动刷新:

const AUTO_REFRESH_INTERVAL = 1000

timerRef.current = setInterval(() => {
  refreshAllLocal()
}, AUTO_REFRESH_INTERVAL)

refreshAllLocal() 并不只是刷新文件状态,它还会刷新:

staging.status
commit.log
branch.list
branch.current
branch.aheadBehind

也就是说,只要打开仓库,前端每秒都会向 Sidecar 请求一组 Git 状态。

这对小仓库可能没什么感觉,但对大仓库来说成本会比较高。更麻烦的是,这种轮询可能和用户操作交织在一起。

比如用户正在切分支、Push、Pull、Reset,后台刷新也同时在跑,就可能出现短暂的状态覆盖。当前代码里已经有一些“先本地刷新,再异步刷新远程”的处理,这说明项目其实已经遇到过这类状态时序问题。

后续更稳的方向可能是:

文件状态:使用更轻量的 watch / debounce
历史记录:只在 commit、checkout、pull、push 后刷新
分支列表:只在 branch 操作或远程刷新后更新
ahead/behind:按需刷新,不必每秒计算

简单说,就是不要把“所有状态”都绑到一个固定轮询里。


十一、凭据存储也暴露出配置层边界问题

这次我还注意到一个比较重要的安全和架构问题。

RepoConfig 里包含:

authPassword?: string
sshPassword?: string

这些字段会跟普通配置一起保存:

writeFileSync(CONFIG_FILE_PATH, JSON.stringify(config, null, 2), 'utf-8')

也就是说,HTTP token 或 SSH key passphrase 可能会以明文形式写进 app 的配置文件。

这个问题不只是安全问题,也说明配置层和认证层现在耦合在一起了。

更合理的结构应该是:

RepoConfig
  -> 保存仓库路径、名称、远程地址、远程类型等非敏感信息

CredentialStore
  -> 通过系统 keychain / credential manager 保存 token 和密码

remotePayload()
  -> 操作时临时组装认证信息,不把敏感字段当普通配置到处传

这部分如果后续要做正式产品化,优先级应该比较高。


十二、仓库里混入了一些阶段性产物

结构体检时,我还看了一下仓库里的非源码内容。

.gitignore 已经忽略了 distoutbuild.tmp 等目录,但当前仓库里仍然有一个被 Git 跟踪的二进制:

sidecar/sidecar.exe

而真正打包使用的 sidecar 二进制在:

resources/intelligit-sidecar.exe

这会让人比较困惑:到底哪个 exe 是开发产物,哪个 exe 是打包资源,哪个应该提交?

另外,docs/ 目录也比较像工作区集合:

docs/czl/
docs/lxy/
docs/zm/
docs/koishi/

里面有需求文档、博客、HTML 原型、demo code、测试文件和个人过程记录。这些内容本身都有价值,但如果不分层,后面查资料时会越来越难。

我觉得可以逐步整理成:

docs/requirements/
docs/design/
docs/blog/
docs/prototypes/
docs/archive/

这样文档就不只是“放得下”,而是“找得到”。


十三、这次检查的结果

我最后跑了三类检查。

TypeScript 类型检查:

npm.cmd run typecheck

结果通过。

Go 测试:

go test ./...

第一次因为 Go 默认 cache 指向用户目录,遇到了权限问题;改成工作区内的 GOCACHE 后,测试通过。

ESLint:

npm.cmd run lint

结果没有通过,主要问题是 App.tsx 中测试面板 hooks 条件调用,另外还有一批 Prettier 警告。

所以这次体检可以总结成:

项目不是坏掉了
项目也不是不能继续开发

但它已经到了应该重构结构的阶段

这类问题不会像运行时报错那样立刻跳出来,但它会慢慢影响每一次修改的成本。


十四、我认为后续最适合的重构顺序

如果后续要整理,我不建议一上来做“大重构”。这个项目现在功能链路已经跑通,最重要的是保持可运行,然后一点一点把结构梳顺。

我会优先按这个顺序来:

第一步:拆 App.tsx
  -> MainApp 和 TestPanel 分离
  -> 修掉 hooks lint 错误

第二步:拆 MainApp.tsx
  -> ActivityRail
  -> RepoSidebar
  -> Toolbar
  -> ChangesView
  -> HistoryView
  -> SettingsView
  -> StatusBar

第三步:拆 useAppStore.ts
  -> 先抽 gitClient
  -> 再按 repository / status / diff / history / ui 分 slice

第四步:拆 CSS
  -> 旧测试面板样式和正式工作台样式分离
  -> 视图样式跟随视图文件

第五步:拆 Go handlers.go
  -> 按 repo / staging / commit / branch / remote / diff / merge 拆文件

第六步:整理协议类型
  -> 建立 Git command map
  -> 减少 response.data as ...

第七步:整理凭据和文档目录
  -> 敏感信息移出普通 config
  -> docs 按用途重新归档

这套顺序的好处是每一步都比较小,也都能单独验证。不会出现“重构一半,项目既跑不了,也回不去”的尴尬状态。


十五、这次体检带来的一个提醒

写功能时,我们很容易把注意力放在“它能不能跑”上。

这个阶段当然重要。尤其是 IntelliGit 这种同时有 Electron、React、Go、Git 协议、系统文件、远程认证的项目,先把链路打通本身就不容易。

但当功能已经能跑以后,项目会进入另一个阶段:代码开始要求我们给它安排房间。

MainApp.tsx 太长,其实是在说:“前端视图该分家了。”

useAppStore.ts 太大,其实是在说:“状态和服务该分层了。”

handlers.go 太集中,其实是在说:“后端命令该按业务域拆开了。”

两个大 CSS 文件互相覆盖,其实是在说:“样式也需要边界。”

这些提醒都不是坏消息。恰恰相反,它说明项目已经从“验证能不能做”走到了“思考怎么长期维护”的阶段。

对 IntelliGit 来说,这是一个挺关键的拐点。接下来如果能把结构整理好,后面的 AI commit、智能暂存、冲突解决、沙箱测试、分支图这些功能,都会更容易稳稳地长上去。

这次体检没有改动业务代码,但它给后续重构画了一张地图。项目不是乱到不能救,而是到了该收拾、该分层、该让代码重新变得清爽的时候。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐