# Git 与版本控制

*Git 是软件团队在不相互覆盖工作的情况下进行协作的方式。本文涵盖 Git 的心智模型、分支策略、合并与变基、冲突解决、拉取请求，以及管理机器学习特定挑战（如大文件和实验追踪）的方法。*

- 每个严肃的软件项目都使用版本控制。**Git** 是主导系统，几乎所有开源项目和公司都在使用。没有 Git，协作就是通过电子邮件发送 zip 文件并祈祷没人覆盖你的更改。有了 Git，每次更改都可追踪、可撤销、可追溯。

- 对于机器学习工程师：Git 追踪你的代码、配置和实验脚本。结合实验追踪工具，它能提供可重现性："是哪个确切的代码和配置产生了这个模型？"

## 心智模型

- Git 追踪项目的**快照**。每次提交都是那一刻所有追踪文件的完整快照，而不是差异（在内部，Git 为效率存储差异，但从概念上讲，每次提交都是一个完整状态）。

- 文件的四个"位置"：

    1. **工作目录**：磁盘上的实际文件。你在这里编辑。
    2. **暂存区**（索引）：你标记为下一次提交的文件。`git add` 将更改移到这里。
    3. **本地仓库**：你的提交历史，存储在 `.git/` 中。`git commit` 将暂存区保存为新的快照。
    4. **远程仓库**（例如 GitHub）：一个共享副本。`git push` 上传你的提交，`git pull` 下载他人的提交。

```
Working Dir  →  git add  →  Staging  →  git commit  →  Local Repo  →  git push  →  Remote
                                                        ←  git pull  ←
```

- 暂存区正是 Git 强大之处。你可以编辑 10 个文件，但只提交其中的 3 个，将其他更改保留给另一次提交。这使得清晰的、有重点的提交成为可能。

### 基本命令

```bash
git init                          # 创建新仓库
git clone url                     # 下载远程仓库
git status                        # 有什么变化？（最常用的命令）
git add file.py                   # 暂存特定文件
git add .                         # 暂存所有更改（谨慎使用）
git commit -m "descriptive msg"   # 提交暂存的更改
git push                          # 将提交上传到远程
git pull                          # 下载并合并远程更改
git log --oneline                 # 紧凑的提交历史
git diff                          # 显示未暂存的更改
git diff --staged                 # 显示已暂存的更改
```

## 分支

- **分支**是指向一次提交的指针。默认分支是 `main`（或 `master`）。创建分支让你拥有独立的开发线：你可以在不影响 `main` 的情况下进行更改。

```bash
git branch feature-x              # 创建分支
git checkout feature-x            # 切换到此分支
git checkout -b feature-x         # 创建并切换（一步完成）
git branch -d feature-x           # 删除分支（合并后）
git branch -a                     # 列出所有分支（本地 + 远程）
```

- **何时分支**：始终需要。永远不要直接提交到 `main`。每个功能、错误修复或实验都有其自己的分支。这保持了 `main` 的稳定性和可部署性。

### 分支策略

- **功能分支**（最常见）：每个功能/修复从 `main` 创建一个分支。完成后，打开拉取请求（PR）以合并回去。简单，适用于大多数团队。

- **主干开发**：开发人员频繁（每天多次）提交到 `main`，使用特性标记隐藏未完成的工作。持续部署的团队（Google、Facebook）更偏好这种方式。需要优秀的 CI/CD。

- **Gitflow**：为功能、发布和热修复设置单独的分支。更复杂，适用于有版本化发布的软件（移动应用、打包软件）。对大多数机器学习项目来说过于复杂。

- 对于机器学习团队：**功能分支**配合短生命周期的分支（1-3 天内合并）是最佳选择。生命周期长的分支会与 `main` 产生分歧，导致痛苦的合并冲突。

## 合并与变基

- **合并**创建一个新的"合并提交"，将两个分支合并：

```bash
git checkout main
git merge feature-x
```

- 这保留了完整的历史记录：你可以看到工作是在分支上完成的，以及何时合并的。合并提交有两个父节点。

- **变基**在你的分支上重放提交到目标分支之上：

```bash
git checkout feature-x
git rebase main
```

- 这会重写历史：你的分支上的提交会获得新的哈希值，就好像你是从 `main` 的当前顶端开始工作一样。结果是线性的历史记录（没有合并提交），阅读起来更清晰。

- **何时使用哪种**：
    - **变基**用于使用最新的 `main` 更改更新你的功能分支（保持分支整洁和最新）。
    - **合并**用于将你的功能分支集成到 `main`（保留分支历史）。
    - **永远不要变基已经推送并与他人共享**的提交。变基会重写历史；如果其他人已经基于原始提交开展工作，变基会导致混乱。

## 解决冲突

- **冲突**发生在两个分支修改同一文件的同一行时。Git 无法自动决定保留哪个更改，需要你手动解决。

```
<<<<<<< HEAD
learning_rate = 0.001
=======
learning_rate = 0.0005
>>>>>>> feature-x
```

- `<<<<<<< HEAD` 和 `=======` 之间是当前分支的版本。`=======` 和 `>>>>>>> feature-x` 之间是传入分支的版本。你决定保留哪个（或组合它们），删除标记，保存，然后运行 `git add` 添加已解决的文件。

- **陷阱**：不要在已提交的文件中留下冲突标记。它们是会破坏你代码的字面文本。解决后始终搜索 `<<<<<<<`。

- **减少冲突**：保持分支短生命周期，频繁将 `main` 合并到你的分支中，避免多人同时编辑同一个文件。

## 编写良好的提交信息

- 提交信息是为了未来的你和你的队友。"修复错误"告诉不了你什么。"修复批次大小计算中的差一错误，该错误导致 8-GPU 训练时 OOM"告诉你一切。

- **格式**：

```
简短摘要（50 字以内，祈使语气）

如果需要，可附带更长的描述。解释 WHY，而不是 WHAT
（差异显示了什么改变了）。每行不超过 72 个字符。

Fixes #123
```

- **祈使语气**："添加功能"而不是"已添加功能"或"添加了功能"。将其视为完成句子："如果应用此提交，它将**添加功能**。"

- **原子提交**：每个提交应做一件事。"添加数据加载器"是一个提交。"添加数据加载器并修复无关的错误并更新 README"应该是三个提交。这使得 `git bisect`（找到哪个提交引入了错误）成为可能。

## 拉取请求与代码审查

- **拉取请求（PR）**提议将一个分支合并到 `main`。它是代码审查的门户：队友阅读你的更改，提出改进建议，并在合并前批准。

- **良好的 PR 实践**：
    - 保持 PR 小（少于 400 行更改）。大的 PR 会被敷衍批准，因为没人想审查 2000 行。
    - 编写清晰的描述：更改了什么、为什么以及如何测试。
    - 链接到促使更改的问题或工单。
    - 及时回复审查评论。
    - 在合并前压缩琐碎的提交（这样 `main` 就有干净的历史记录）。

- **代码审查不是为了找错误**（测试来做这个）。它的目的是：知识分享（审查者学习代码库）、设计反馈（这是正确的方法吗？）和维护标准（命名、风格、架构）。

## .gitignore

- `.gitignore` 文件告诉 Git 排除哪些文件不被追踪。对于机器学习项目：

```gitignore
# Python
__pycache__/
*.pyc
*.egg-info/
.venv/
env/

# 数据和模型（对 git 来说太大）
data/
*.csv
*.parquet
models/
*.pt
*.onnx
*.bin
checkpoints/

# 密钥
.env
*.pem
credentials.json

# IDE
.vscode/
.idea/
*.swp

# 操作系统
.DS_Store
Thumbs.db

# Jupyter
.ipynb_checkpoints/

# 实验输出
wandb/
mlruns/
outputs/
logs/
```

- **陷阱**：在文件已被提交后将文件添加到 `.gitignore` 不会将其从仓库中移除。你还必须使用 `git rm --cached file` 来取消追踪。该文件会永远留在历史中，除非你重写历史（这很麻烦）。

## Git 在机器学习中的应用

- 机器学习引入了传统软件不面临的挑战：

- **大文件**：数据集和模型权重可能有数 GB 或更大。Git 是为文本文件（源代码）设计的，而不是二进制 blob。解决方案：
    - **Git LFS**（大文件存储）：在 Git 中追踪指针，将实际文件存储在单独的服务器上。简单，但在 GitHub 上有限制存储/带宽。
    - **DVC**（数据版本控制）：将数据和模型文件与 Git 分开管理，使用远程存储（S3、GCS）。像 Git 一样用于数据：`dvc add data.csv`、`dvc push`、`dvc pull`。

- **实验追踪**：哪个提交 + 哪些超参数 + 哪个数据产生了哪些指标？Git 追踪代码，但不追踪完整的实验上下文。
    - **Weights & Biases（W&B）**：记录指标、超参数、系统信息，并链接到 Git 提交。提供用于比较运行结果的仪表板。
    - **MLflow**：开源的实验追踪，带有模型注册表。记录参数、指标和产物。
    - **简单方法**：在你的训练脚本中记录 Git 哈希值：`git_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD']).strip()`。将其与你的结果一起存储。

- **可重现性检查清单**（每个实验需要追踪的内容）：
    - Git 提交哈希值（确切的代码版本）
    - 配置文件 / 超参数
    - 随机种子
    - Python 和库版本（`pip freeze`）
    - 数据版本（DVC 哈希值或数据集版本标签）
    - 硬件（GPU 类型、GPU 数量）

```bash
# 快速可重现性快照
echo "Commit: $(git rev-parse HEAD)" > experiment_info.txt
echo "Branch: $(git branch --show-current)" >> experiment_info.txt
echo "Dirty: $(git status --porcelain | wc -l) files" >> experiment_info.txt
pip freeze >> experiment_info.txt
nvidia-smi >> experiment_info.txt
```
