所有托管服务节省 15%

测试技能,享折扣

使用代码: Skills 开始使用
China
Linux 虚拟服务器

Git 仓库结构:完整技术指南

Git 是一个分布式版本控制系统,它将项目历史存储为不可变快照对象的有向无环图(DAG)。每个 Git 仓库由三个逻辑区域构成——工作目录、暂存索引以及 .git/ 内的对象存储——加上一组用于导航历史记录的轻量级指针(分支、标签、远程)。理解这些层之间的交互方式,是机械地使用 Git 与精准地使用 Git 之间的本质区别。

如果您在 VPS 上自托管仓库,掌握这种内部结构可以让您从灾难中恢复、设计高效的 CI/CD 流水线,并在不依赖第三方平台的情况下审计项目历史的每一个字节。

三区域模型:Git 如何移动数据

在深入了解各个组件之前,请先理解支配每个 Git 操作的数据流模型:

Working Directory  -->  Staging Area (Index)  -->  .git/ Object Store
     (edit)               (git add)                  (git commit)

构建提交时,变更从左向右流动;恢复或重置时,变更从右向左流动。每个 Git 命令本质上都是对这些区域中的一个或多个进行读或写操作。

工作目录

工作目录(也称为工作树)是您的项目在特定检出状态下的文件系统视图。当您运行 git clonegit checkout 时,Git 从 .git/objects/ 中的压缩对象重建文件并将其写入该目录。

工作目录中的文件存在以下四种状态之一:

  • 未跟踪 — Git 从未见过此文件;它仅存在于磁盘上。
  • 已跟踪,未修改 — 文件与最后一次提交的快照完全匹配。
  • 已跟踪,已修改 — 文件与最后一次提交的快照不同,但尚未暂存。
  • 已跟踪,已删除 — 文件已从磁盘中删除,但删除操作尚未暂存。

一个让许多开发者困惑的关键细节:工作目录不是仓库的简单副本。Git 通过读取树对象并解压缩 blob 对象来重建它。如果 .git/ 完好无损,您始终可以从头重新生成工作目录——反之则不然。

大型 Monorepo 的稀疏检出

对于拥有数万个文件的仓库(在 monorepo 架构中很常见),您可以限制 Git 在工作目录中实体化的路径:

git sparse-checkout init --cone
git sparse-checkout set services/api services/auth

这在磁盘 I/O 受限的 VPS 上非常有价值,因为 Git 会跳过解压缩锥形范围之外路径的 blob。

暂存区(索引)

暂存区,内部称为索引,是一个位于 .git/index 的二进制文件。它充当下一次提交的草案——一个位于工作目录和永久对象存储之间的可变快照。

git add <file>          # Stage a specific file
git add -p              # Interactively stage hunks within a file
git add -u              # Stage all tracked modifications and deletions
git status              # Compare working directory and index against HEAD
git diff --cached       # Show diff between index and HEAD

索引存在的原因

索引解决了更简单的 VCS 工具所忽视的一个问题:部分提交。您可能修改了五个文件,但只想将其中三个包含在下一次提交中。索引让您可以精确地组合您打算记录的快照,而不受编辑器中打开内容的影响。

边缘情况——索引损坏:如果系统崩溃中断了 git add,索引文件可能会损坏。症状包括 git status 挂起或报告奇怪的输出。恢复方法:

rm .git/index
git reset

Git 会从 HEAD 重建索引,而不会影响您的工作目录。

索引作为合并冲突寄存器

在合并冲突期间,索引同时存储每个冲突文件的三个版本(阶段 1、2 和 3——基础版本、我们的版本、他们的版本)。这就是为什么 git diff --cached 在冲突中途没有显示有用信息;您需要 git diff --cc 或合并工具来检查所有三个阶段。

.git/ 目录:对象存储的剖析

.git/ 目录就是仓库本身。其他一切——工作目录、远程克隆——都是从它派生的。删除 .git/ 会将仓库变成一个没有历史记录的普通目录。

.git/
├── HEAD
├── config
├── description
├── index
├── COMMIT_EDITMSG
├── hooks/
├── info/
├── logs/
│   ├── HEAD
│   └── refs/
├── objects/
│   ├── info/
│   └── pack/
└── refs/
    ├── heads/
    ├── remotes/
    └── tags/

HEAD 是一个纯文本文件,包含符号引用(指向分支)或原始 SHA-1 哈希(分离 HEAD 状态)。

cat .git/HEAD
# ref: refs/heads/main        <-- on a branch
# a3f1c9d...                  <-- detached HEAD

分离 HEAD 不是错误状态——当您检出标签或特定提交进行检查时,这是有意为之的。危险在于在分离 HEAD 状态下进行提交:这些提交只能通过 reflog 访问,直到您将它们附加到分支为止。

git checkout -b rescue-branch   # Attach detached commits to a new branch

config

本地仓库配置文件。它覆盖全局(~/.gitconfig)和系统(/etc/gitconfig)设置。常见条目:

[core]
    repositoryformatversion = 0
    filemode = true
    bare = false
[remote "origin"]
    url = git@github.com:user/repo.git
    fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
    remote = origin
    merge = refs/heads/main

在自托管服务器上,当轮换远程 URL 或为部分克隆配置 uploadpack.allowReachableSHA1InWant 时,您会经常直接编辑此文件。

refs/

refs/ 目录包含纯文本文件,每个文件保存一个 SHA-1 哈希。它们是使 Git DAG 可导航的命名指针。

引用类型路径描述
本地分支refs/heads/<name>指向分支的最新提交
远程跟踪分支refs/remotes/<remote>/<name>远程分支最新提交的本地缓存
轻量级标签refs/tags/<name>直接指向提交对象
附注标签refs/tags/<name>指向标签对象,标签对象再指向提交
储藏refs/stash指向储藏提交

为了提高性能,当仓库积累了大量引用后,Git 会将引用打包.git/packed-refs 中。在针对引用编写脚本时,始终检查两个位置。

Git 对象:不可变的核心

存储在 .git/objects/ 中的所有内容都是内容寻址的:文件名是对象内容的 SHA-1(或较新 Git 版本中的 SHA-256)哈希。这使 Git 天然具有防篡改性——更改任何字节都会改变哈希,从而破坏链条。

四种对象类型

对象类型存储内容指向
Blob原始文件内容(无文件名,无权限)
Tree目录列表:文件名、权限、blob/tree SHABlob 和其他 tree
Commit作者、提交者、时间戳、消息、父 SHA一个 tree + 零个或多个父提交
Tag标签者身份、时间戳、消息、GPG 签名通常是一个提交

直接检查对象

# Show the type of any object
git cat-file -t a3f1c9d

# Show the content of any object
git cat-file -p a3f1c9d

# Show the tree of the current HEAD commit
git ls-tree HEAD

# Show a specific blob's content
git show HEAD:src/main.py

松散对象与包文件

最初,每个对象作为单独的压缩文件存储在 .git/objects/<2-char-prefix>/<38-char-suffix> 下。这些是松散对象。随着时间推移,Git 会运行 git gc(垃圾回收)将松散对象打包成包文件.git/objects/pack/*.pack),并附带相应的索引(.pack.idx)。

包文件使用增量压缩——存储相似对象之间的差异,而不是完整副本。拥有数千个相似文本文件的仓库在打包后可以显著缩小。在 NVMe 容量有限的 VPS 上,在归档前对大型仓库运行 git gc --aggressive 是标准做法。

git count-objects -vH    # Show loose object count and disk usage
git gc --aggressive      # Repack aggressively (CPU-intensive)
git verify-pack -v .git/objects/pack/*.idx | sort -k3 -n | tail -20
# Find the 20 largest objects in the pack

提交历史:有向无环图

每个提交对象恰好包含一个指向树对象(根目录快照)的指针和零个或多个指向父提交的指针。这形成了一个 DAG,其中:

  • 零个父节点 = 初始提交(根提交)
  • 一个父节点 = 普通提交
  • 两个父节点 = 合并提交
  • 三个或更多父节点 = 章鱼合并(罕见,用于同时集成多个功能分支)
git log --oneline --graph --all    # Visualize the full DAG
git log --format="%H %P"           # Show each commit's SHA and parent SHA(s)

提交不可变性与重写历史

由于提交的 SHA 是从其内容(包括父 SHA)派生的,任何重写都会创建具有新 SHA 的新提交git rebasegit commit --amendgit filter-repo 等操作不会修改历史——它们创建并行历史。旧提交保留在对象存储中,直到被垃圾回收。

这就是为什么强制推送重写的历史到共享分支是破坏性的:协作者的本地分支仍然指向旧的提交链。

分支:轻量级指针

分支只不过是一个包含 SHA-1 哈希的 41 字节文件。无论仓库大小如何,创建分支都是即时的,因为 Git 只写入一个小文件。

git branch feature/auth           # Create branch at current HEAD
git checkout -b feature/auth      # Create and switch in one step
git switch -c feature/auth        # Modern equivalent (Git 2.23+)
git branch -d feature/auth        # Delete (safe: refuses if unmerged)
git branch -D feature/auth        # Delete (force: regardless of merge status)

分支内部结构

cat .git/refs/heads/main
# a3f1c9d8e2b1f4c7d9e0a1b2c3d4e5f6a7b8c9d0

当您在分支上提交时,Git 将新的提交 SHA 写入此文件。这就是”推进分支指针”的全部内容。

跟踪分支与上游配置

跟踪关系告诉 Git 本地分支应与哪个远程分支进行比较,用于 git status 差异报告和 git pull 行为。

git branch --set-upstream-to=origin/main main
git branch -vv    # Show tracking relationships and ahead/behind counts

标签:历史中的永久标记

标签将特定提交标记为重要——通常是软件发布版本。与分支不同,标签不会随新提交而移动。

特性轻量级标签附注标签
存储指向提交的引用文件对象存储中的标签对象
元数据标签者姓名、邮箱、日期、消息
GPG 签名不支持通过 git tag -s 支持
推荐用于发布
通过 git push --tags 传输
git tag v2.1.0                              # Lightweight tag at HEAD
git tag -a v2.1.0 -m "Release 2.1.0"       # Annotated tag
git tag -s v2.1.0 -m "Signed release"      # GPG-signed annotated tag
git push origin --tags                      # Push all tags to remote
git push origin v2.1.0                      # Push a specific tag

关键陷阱:git push 默认不推送标签。团队经常忘记这一点,发布的版本说明引用了远程上不存在的标签。

远程:分布式协作

远程是存储在 .git/config 中的命名 URL。远程跟踪分支(在 refs/remotes/ 下)是远程分支的本地只读快照,仅在您显式获取时更新。

git remote add origin git@github.com:user/repo.git
git remote -v                          # List remotes with URLs
git remote set-url origin <new-url>    # Change a remote URL
git fetch origin                       # Update remote-tracking branches
git fetch --prune                      # Remove stale remote-tracking branches
git push origin main                   # Push local main to remote
git push -u origin feature/auth        # Push and set upstream tracking

多个远程

单个仓库可以跟踪多个远程——在维护 fork 的同时跟踪上游时很常见:

git remote add upstream git@github.com:original/repo.git
git fetch upstream
git merge upstream/main

当在独立服务器上为团队自托管裸仓库时,每个开发者将服务器添加为远程,并使用 SSH 密钥认证进行推送访问。

钩子:在每个 Git 事件处自动执行

钩子是 .git/hooks/ 中的可执行脚本。Git 在工作流的特定节点调用它们。它们不会通过 git clonegit push 传输——每个开发者(或服务器)必须独立安装它们。这是团队环境中常见的混淆来源。

客户端钩子

钩子触发时机常见用途
pre-commit提交消息提示之前代码检查、密钥扫描、测试执行
prepare-commit-msg创建默认消息之后将分支名称注入消息
commit-msg用户写入消息之后强制执行约定式提交格式
post-commit提交记录之后本地通知
pre-pushgit push 执行之前运行完整测试套件
pre-rebaserebase 开始之前防止对已发布分支进行 rebase

服务器端钩子

钩子触发时机常见用途
pre-receive引用更新之前强制分支保护,拒绝强制推送
update接收期间每个引用按分支策略执行
post-receive所有引用更新之后触发 CI/CD,发送通知

示例:用于密钥检测的 Pre-commit 钩子

#!/usr/bin/env bash
# .git/hooks/pre-commit

if git diff --cached --name-only | xargs grep -lE '(AKIA|passwords*=|api_keys*=)' 2>/dev/null; then
    echo "ERROR: Potential secret detected in staged files. Commit aborted."
    exit 1
fi
exit 0

使其可执行:

chmod +x .git/hooks/pre-commit

对于团队范围的钩子分发,使用 Husky(Node.js 项目)等工具,或将钩子存储在仓库根目录的 hooks/ 目录中,并在项目设置期间创建符号链接。

Reflog:安全网

reflog 记录 HEAD 和分支指针的每次移动,包括看似销毁历史的操作(硬重置、rebase、修改提交)。它存储在 .git/logs/ 中。

git reflog                          # Show HEAD movement history
git reflog show main                # Show movement history for a specific branch
git checkout HEAD@{3}               # Check out the state HEAD was in 3 moves ago
git branch recovered HEAD@{5}       # Recover commits by branching from a reflog entry

Reflog 条目默认在 90 天后过期(gc.reflogExpire)。在生产服务器上,考虑延长此期限:

git config gc.reflogExpire 180
git config gc.reflogExpireUnreachable 30

裸仓库:服务器端托管

裸仓库没有工作目录。它在根级别只包含 .git/ 的内容。裸仓库是集中式托管的正确格式——它们接受推送,而不会有已检出分支带来的复杂性。

git init --bare /srv/repos/myproject.git

当您推送到 GitHub、GitLab 或自托管 Git 服务器时,您是在推送到裸仓库。如果您在带 cPanel 的 VPS 或原始 Linux VPS 上托管自己的 Git 服务器,/srv/repos/ 下通过 SSH 访问的裸仓库是标准架构。

初始化共享裸仓库

# On the server
git init --bare --shared=group /srv/repos/project.git
chown -R git:developers /srv/repos/project.git

# On a developer's machine
git remote add origin git@yourserver.com:/srv/repos/project.git
git push -u origin main

Git 对象存储:大小、完整性与维护

检查仓库健康状况

git fsck --full          # Verify object integrity (finds dangling and corrupt objects)
git fsck --lost-found    # Write dangling objects to .git/lost-found/

查找和删除大型对象

意外提交的大型二进制文件是仓库膨胀的常见原因。在使用 git filter-repo 删除它们之前先识别它们:

# Find the 10 largest objects by compressed size
git verify-pack -v .git/objects/pack/*.idx 
  | sort -k3 -rn 
  | head -10 
  | awk '{print $1}' 
  | xargs -I{} git cat-file -p {}
# Remove a file from all history (requires git-filter-repo)
git filter-repo --path path/to/large-file.bin --invert-paths

过滤后,所有协作者必须重新克隆——他们的本地仓库引用的 SHA 哈希在重写的历史中不再存在。

比较:Git 仓库关键概念

概念类型可变存储位置通过推送/获取传输
Blob对象.git/objects/是(可达时)
Tree对象.git/objects/是(可达时)
Commit对象.git/objects/是(可达时)
附注标签对象.git/objects/仅通过 --tags
分支引用.git/refs/heads/
远程跟踪分支引用是(获取时).git/refs/remotes/否(本地缓存)
轻量级标签引用.git/refs/tags/仅通过 --tags
HEAD符号引用/哈希.git/HEAD
索引二进制文件.git/index
钩子脚本.git/hooks/
Reflog日志是(自动过期).git/logs/

实用决策矩阵与关键要点

在基础设施上设置或审计 Git 仓库时,请使用此检查清单:

仓库初始化

  • 对任何将接收多个用户推送的仓库使用 git init --bare --shared=group
  • 将裸仓库存储在 Web 可访问目录之外(永远不要放在 /var/www/ 下)。

对象存储健康

  • 在任何存储事故或文件系统错误后运行 git fsck --full
  • 在长期运行的仓库上定期安排 git gc;通过服务器上的 cron 自动化执行。
  • 使用 git count-objects -vH 监控包文件大小;如果松散对象数量超过 1,000,请进行调查。

分支和引用卫生

  • 及时删除已合并的分支;过时的引用会积累并降低 git fetch --prune 操作的速度。
  • 在 CI 流水线中使用 git fetch --prune 以避免对已删除的远程分支进行操作。

钩子部署

  • 永远不要依赖 .git/hooks/ 来实施团队范围的策略——钩子不会被克隆。改用服务器端 pre-receive 钩子或 CI 门控。
  • 每次 Git 服务器升级后审计服务器端钩子;钩子解释器路径可能会改变。

自托管服务器上的安全性

  • 使用强制命令(command=authorized_keys 中)限制 git 用户的 SSH 访问。
  • 使用 git-shell 作为 git 用户的登录 shell,以防止任意命令执行。
  • 如果您公开任何 Web 界面(Gitea、GitLab、cgit),请为您的仓库服务器配置有效的 SSL 证书

历史重写

  • 在没有协调迁移计划的情况下,永远不要重写与他人共享的分支历史。
  • git filter-repo 之后,所有协作者必须重新克隆;立即更新 CI/CD 远程 URL。

灾难恢复

  • 在生产服务器上延长 reflog 过期时间(gc.reflogExpire = 180)。
  • 在单独的主机上保留一个辅助裸克隆作为备份;从主服务器执行简单的 git fetch 即可。

常见问题

裸仓库和非裸 Git 仓库有什么区别?

非裸仓库有一个文件被检出的工作目录,加上一个包含对象存储的 .git/ 子目录。裸仓库在其根目录只包含对象存储(没有工作目录),是接受推送的共享服务器的正确格式。

运行 git reset --hard 后可以恢复提交吗?

是的,只要提交尚未被垃圾回收。运行 git reflog 找到您想恢复的提交的 SHA,然后运行 git checkout -b recovery-branch <SHA> 将其附加到新分支。Reflog 条目默认保留 90 天。

为什么 git push 不传输我的标签?

按设计,git push 只传输从您显式推送的引用可达的提交。标签是单独的引用,必须通过 git push origin --tags(所有标签)或 git push origin <tagname>(特定标签)推送。

合并冲突期间索引会发生什么?

索引同时存储每个冲突文件的所有三个版本:阶段 1(共同祖先/基础)、阶段 2(您的版本)和阶段 3(他们的版本)。正常的 git add 只写入阶段 0(已解决)。在所有冲突解决并暂存之前,git commit 将拒绝继续。

Git 钩子在客户端和服务器端部署之间有何不同?

客户端钩子在开发者的机器上运行,不会集中强制执行——任何开发者都可以通过删除钩子文件来绕过它们。服务器端钩子(pre-receiveupdatepost-receive)在托管服务器上运行,客户端无法绕过,使其成为分支保护策略、代码审查要求和 CI/CD 触发器的正确执行点。

Linux 管理
专用服务器 安全 虚拟服务器
Linux 虚拟服务器