周五下午四点。一家 A-股 私募 的基金经理走过来问:「昨天你跑的 沪深300 动量回测,能不能重现?早上 Sharpe 看起来不太对。」你打开 notebook,已经被改过两次——因子回看从 60 个交易日改成了 90 个,滑点假设也不知何时动过。没有 git 时你在靠记忆复原数字;有了 git,git log --oneline、git checkout <sha>,昨天的状态回到硬盘。其他 VCS(Mercurial、Subversion、Jujutsu)也存在——但团队用的是 git,本课就教 git。
Git 是什么,不是什么
Git 是一个按内容寻址的快照存储(content-addressed snapshot store)。每一次提交(commit)是仓库当时全部文件的一份快照,标识是内容的 SHA-1(内容相同则哈希相同)。文件存为 blob、目录存为 tree、提交指向一棵 tree 与父提交。要记住「git 存的是快照(snapshot),不是增量(delta)」——git diff 是临时算出来的。Git 不是:不是同步工具(同步是 Dropbox),不是备份(要备份请推到远端),也不是分享大数据的方式(请走共享挂载点或 dvc——.gitignore 拒绝跟踪 *.csv.gz 是有原因的)。
三棵树:工作区、暂存区、HEAD
每一条 git 命令都在三层之间搬运内容。第一天就把这张图烙进脑子里:
working tree— 硬盘上的文件,就是你能cat和ls的那些。index—.git/index;由git add写入。下一次提交的暂存区(staging area)。HEAD— 当前分支上最近一次提交的指针。
你改一个文件,它就和 index 分歧;git add 把工作区里的版本拷进 index;git commit 给 index 拍一张快照,并把 HEAD 向前推一步。为什么 index 要做成一层独立的中间层、而不是「凡是改过的都直接提交」?因为真实编辑过程里你的修改是混的——你顺手修了一个错别字、调了一下动量回看窗口,还留了一行 print(df.head()) 没删——index 让你一次只暂存一个逻辑变化,提交信息就只描述一件事。
日常循环:在一个研究仓库上走一遍
你在 ~/research/research-alpha/ 检出了一个研究仓库 research-alpha。你修改 src/factors/momentum.py,把回看窗口从 90 个交易日调到 60 个。走一遍这六条命令的日常循环:
git status
git diff src/factors/momentum.py
git add -p src/factors/momentum.py
git diff --staged
git commit -m "feat(factors): tune momentum lookback to 60d"
git log --oneline --graph --decorate -5
git status 告诉你工作区与暂存区各有哪些变化;刚改完你会看到 momentum.py 是 modified、暂存区为空。git diff 展示未暂存差异——工作区对比 index。git add -p 是关键的纪律工具:逐段(hunk)问你 Stage this hunk [y,n,q,a,d,e,?]?,于是只把回看窗口那段加进暂存区,把那行 debug print 留给下一次单独提交(更好的是提交前删掉)。git diff --staged 展示下一次提交里确切进去的东西——是抓住差点提交进去的 debug print 的最后机会。git commit -m "..." 给 index 拍快照、HEAD 前进一步;信息遵循团队约定(第 2 课)。git log --oneline --graph --decorate -5 把最近五个提交画成一行图——审查者扫分支历史用的就是这个视图。
其它检查命令:git show <sha> 打印某次提交的 diff 与信息;git diff HEAD~1 与一次提交前比较;git blame src/factors/momentum.py 给每行标注最后改它的提交与作者(理解意图,不是道德审判)。
.gitignore:不提交数据、不提交密钥、不提交 notebook 输出
仓库根目录下的 .gitignore 文件列出 git 拒绝跟踪的路径模式。对量化研究仓库,每个团队都用差不多这样一份种子:
data/
*.parquet
*.csv.gz
*.h5
*.pkl
.ipynb_checkpoints/
__pycache__/
*.pyc
.env
.venv/
*.so
data/ 把整棵数据目录挡在 git 之外(数据按 3.6.1 住在共享挂载点上)。*.parquet、*.csv.gz、*.h5、*.pkl 是双保险——哪怕 tick_510300_20250424.csv.gz 误入 notebooks/,也不会被提交。.ipynb_checkpoints/ 与 nbstripout / jupytext 负责把 notebook 的运行输出挡在 diff 之外(提交代码,不提交跑出来的结果)。__pycache__/ 与 *.pyc 是字节码。.env 是密钥。.venv/ 是虚拟环境。*.so 是编译扩展。三条「永不提交」明确说出:绝不提交数据、绝不提交密钥、绝不提交包含运行结果的 notebook 输出。这份纪律在你第一次带新同事时还回来——他 git clone,拿到的是干净代码,不附赠半 GB 过期 parquet。
分支与第一次合并
分支(branch)是一个可移动的指向某个提交的指针;新建一个分支就是往 .git/refs/heads/ 写一行,开销基本为零。git branch 列出所有分支并给当前分支打星号;git switch -c feature/risk-factor-z 创建并切到新分支;git switch main 切回 main;git switch - 跳到上一次所在的分支。走一遍分支加合并的流程:
git switch -c feature/risk-factor-z
# 编辑并提交两次
git switch main
git merge feature/risk-factor-z
git log --oneline --graph --decorate --all
git branch -d feature/risk-factor-z
建分支之后 main 一步未动,这次合并是快进合并(fast-forward):没有新提交,main 直接滑到分支末端,图形是一条直线。main 在你建分支期间被同事推进过,就是三方合并(three-way merge):git 造一个有两个父提交的合并提交,图形从一点分叉再汇回一点。git log --graph --all --decorate 画出两种形态——* 与 | 就是分支分岔与汇合的轨迹。变基(rebase)只用一句引入、选择留给 第 2 课:git switch feature/risk-factor-z; git rebase main 把分支提交在最新 main 上「重放」,得到线性历史而非合并提交。一句话:merge weaves; rebase replays;什么时候用哪个,下一课讲。
远程:clone、fetch、pull、push
远程(remote)是一个具名 URL,指向另一个仓库,按习惯把你 clone 来源的那个起名为 origin。git clone <url> 从远端初始化一个本地仓库并配好 origin;git remote add origin <url> 则是给一个已经存在的本地仓库补上 origin。本课的样例仓库住在 量化firm 内网 GitLab 上:
git remote add origin git@gitlab.firm.local:research/research-alpha.git
git remote -v
git push -u origin main
git push -u origin feature/risk-factor-z
git remote -v 列出全部远程,附 fetch 与 push URL。git push -u origin <branch> 推分支并设置上游跟踪,以后 git push 与 git pull 都不再需要参数。鉴权层面:团队仓库按 3.6.1 用 ~/.ssh/ 里的 SSH key;HTTPS + PAT 是备选——本课不展开。
git fetch origin 下载远端提交、更新 origin/main 等远程跟踪分支,但不动本地分支;git pull 默认 = fetch + merge,若 pull.rebase=true 则 = fetch + rebase(多数团队为线性历史会这样设)。git push 被拒并提示 non-fast-forward 通常是上次 pull 之后又有人推了东西;先 git pull、再 git push。
Exercise
Exercise
初始化一个全新的 research-alpha 仓库并得到如下历史。(a) 在一个新目录里 git init;创建 src/factors/momentum.py 里有一个 5 行的桩函数、tests/test_momentum.py 里有一条断言;建一份 .gitignore,内容是上文那 11 行种子。(b) 把这三个文件暂存并以一次提交 feat: scaffold momentum factor 落地。(c) 创建分支 feature/risk-factor-z;新增 src/factors/risk_z.py,里面放一个桩;提交 feat(factors): add risk-z stub;切回 main,以快进方式合并这个分支。(d) 加一个远程 origin,URL 自行填一个;跑 git remote -v 确认;不要推送(评分用的远程并不存在)。(e) 把最后的 git log --oneline --graph --decorate --all 输出贴出来,确认它在 main 上是一条直线、共两个提交、没有合并提交(确实走了快进合并)。
提示
git init,再 mkdir -p src/factors tests 然后开始写桩文件。先把 .gitignore 写好,免得本地之前跑过 Python 留下的 __pycache__/ 不小心进了暂存区。提示
main 没有动过——所有改动只在 feature/risk-factor-z 上做。git switch main; git merge feature/risk-factor-z 之后 main 会直接滑到分支末端,不产生新的合并提交。下一课
日常命令、.gitignore、分支、第一次推送——这是单人开发的底座。下一课打开团队层:GitLab MR 完整工作流、五条代码审查纪律、冲突解决全程、git rebase -i 整理本地历史、git reflog 救命。那一课「只对未共享的提交做变基」的红线,靠的就是今天的变基直觉。
阅读清单
- 《Pro Git》第二版 中文版(官方 中文 翻译,git-scm.com/book/zh/v2),关于 Git 基础、分支、远程 的章节。
- 廖雪峰 的 Git 教程,「工作区、暂存区、HEAD」与「分支管理」两节。
- Gitee 中文 帮助 关于 SSH key 配置 与 仓库 fork 的页面。
- GitLab 中文 文档 关于 仓库 创建 与 推送 的快速上手页。
一条额外注释:A-股 量化团队 的代码仓库 通常 部署 在 firm-internal GitLab;GitHub / Gitee 仅用于开源组件 与 个人项目。命令与 .git/ 数据模型在任何远端都一致,工作流直接复用。把整条纪律收成一句:小步提交,常提交;只提交代码,不提交数据;提交信息说清「为什么」。
速查卡
本课反复出现的两种展示形态——抄进自己的笔记:
- Fenced
```bash块——六步日常循环、分支 + 快进合并、四步远程配置。 - Fenced
```text块——十一行.gitignore种子。 - Inline 代码引用——三棵树各层名字(
working tree、index、HEAD)、.git/index路径、纪律命令git add。 - Exercise——
research-alphagit init演练,附 Two 条渐进 Hints。