大四上 生成式软件工程 第三课:软件仓库管理:从“作业 V1、V2”到 Git 20260915
软件仓库管理:从“作业 V1、V2”到 Git
(AI辅助生成)
南京大学《生成式软件工程》课程笔记
主题:软件仓库管理、Git、软件工程 Best Practice 与 Agent 时代的开发方式
1. 从软件工程项目开始
开始一个软件工程项目,最直观的形式就是创建一个目录,然后在其中逐渐加入:
- 源代码
- 文档
- 配置文件
- 测试
- 构建脚本
- 其他与项目相关的资源
从这个角度看,可以把一个软件项目理解为一个不断变化的目录。
软件本身也不仅仅是“代码”。更抽象地说,软件是现实世界中的需求在信息世界中的实现。代码、文档、配置、测试等内容共同构成了这个实现。
因此,软件工程中的一个核心问题就变成了:
如何可靠地管理这个不断变化的项目目录?
2. 为什么需要版本控制
最原始的版本管理方法其实很简单:
作业/
作业_v2/
作业_v3/
作业_最终版/
作业_最终版2/
这种方法本质上已经包含了版本控制的需求:
- 保存某一时刻的项目状态;
- 查看以前的版本;
- 在修改失败后恢复;
- 比较两个版本发生了什么变化。
如果进一步要求:
列出所有历史版本
切换到任意一个历史版本
比较不同版本
让多个人同时修改
把不同人的修改合并起来
那么就会逐渐推导出一个完整的版本控制系统。
Git 正是解决这类问题的工具。
3. Git 的核心思想:保存项目快照
理解 Git 时,一个非常重要的概念是:
Git 管理的是项目在不同时间点的快照。
可以把项目历史想象成:
S0 -> S1 -> S2 -> S3 -> S4
其中每一个 S 都表示项目在某一个时间点的状态。
每次完成一次有意义的修改,就生成一个新的快照:
修改代码
↓
形成新的项目状态
↓
commit
↓
保存为新的历史节点
因此,Git 不应该被理解成:
项目 V1
项目 V2
项目 V3
而更应该理解成:
项目
├── commit 1
├── commit 2
├── commit 3
└── commit 4
这些 commit 共同构成项目的历史。
4. Git 最基础的工作流
4.1 初始化仓库
对于一个已有目录:
git init
执行后会生成:
.git/
这个目录保存 Git 管理项目所需的数据。
4.2 查看当前状态
git status
它可以帮助我们判断:
- 哪些文件没有被 Git 跟踪;
- 哪些文件发生了修改;
- 哪些修改已经进入暂存区;
- 哪些内容会进入下一次提交。
4.3 将修改加入暂存区
git add hello.c
也可以:
git add .
需要注意:
git add .
虽然方便,但意味着把当前目录下的大量修改一起加入暂存区。
在真实项目中,更好的习惯是先确认:
git status
git diff
理解自己到底修改了什么,再决定哪些内容应该进入这次提交。
4.4 创建提交
git commit -m "add hello program"
一次 commit 应当表达一次明确的修改。
例如:
实现登录功能
修复数组越界
增加单元测试
重构配置加载模块
更新 README
而不是把几十件互不相关的事情一次性塞进一个 commit。
5. Commit Message 为什么重要
Commit 不只是“保存一下代码”。
一个好的 commit message 可以记录:
项目为什么发生了这次变化
长期来看:
commit history
↓
项目演化历史
↓
什么时候新增功能
什么时候修复 Bug
什么时候进行了重构
什么时候改变设计
因此下面这种信息价值很低:
update
fix
change
some changes
final
更好的提交应直接说明修改目的,例如:
fix parser crash on empty input
add cache for first-page requests
refactor configuration loader
update deployment documentation
6. Conventional Commits
一种常见的提交规范是 Conventional Commits。
基本格式:
<type>[optional scope]: <description>
例如:
feat: add login page
fix: handle empty input
docs: update README
refactor: simplify parser
test: add parser unit tests
常见类型:
| 类型 | 含义 |
|---|---|
feat |
新功能 |
fix |
Bug 修复 |
docs |
文档修改 |
refactor |
重构 |
test |
测试相关 |
chore |
工程维护 |
重点并不是机械记忆这些单词,而是让项目的提交历史保持:
清晰
一致
可理解
可搜索
可自动处理
7. Commit Often:小步提交
Git 中一个很重要的工程习惯是:
尽量把修改拆成小而完整的 commit。
假设一次修改了 10000 行代码,并同时:
增加功能 A
增加功能 B
修改数据库
调整 UI
重构工具函数
修改配置
之后出现 Bug 时,很难判断究竟是哪一部分引起的。
更理想的情况是:
C1: add parser interface
C2: implement parser
C3: add parser tests
C4: handle malformed input
C5: update documentation
这样每一个 commit 都对应一个明确变化。
小步提交带来的好处包括:
- 更容易审查;
- 更容易定位 Bug;
- 更容易回滚;
- 更容易合并;
- 更容易理解项目历史;
- 更适合多人和多 Agent 并行协作。
8. Atomic Change
可以进一步把一次理想的 commit 看成一个 Atomic Change(原子修改)。
它应该:
足够小
+
完成一件明确的事情
+
提交之后项目仍然处于合理状态
例如:
修改函数
+
修改对应测试
+
测试能够通过
可以组成一次完整提交。
而不推荐:
commit 1: 写一半代码,无法编译
commit 2: 再补一点
commit 3: 修到终于可以运行
好的提交历史本质上也是一种项目结构。
9. 不是什么东西都应该进入 Git
使用:
git add .
时很容易把没有必要保存的东西加入仓库。
常见例子:
编译产物
临时文件
日志
IDE 配置
操作系统生成文件
缓存
依赖目录
密钥
API Key
因此通常需要:
.gitignore
例如:
build/
dist/
*.log
.DS_Store
node_modules/
.env
需要注意:
.gitignore 主要用于告诉 Git 哪些尚未被跟踪的文件应当被忽略。
如果一个文件已经进入 Git 历史,仅仅把它加入 .gitignore 并不能自动取消跟踪。
10. Tag:给重要版本命名
Commit 是不断前进的历史节点。
但有些节点具有特殊意义,比如:
第一个可以发布的版本
课程最终提交版本
v1.0
v2.0
这时可以使用 tag。
例如:
git tag v1.0
或者创建带说明的 tag:
git tag -a v1.0 -m "release v1.0"
于是:
commit A
|
commit B
|
commit C <- v1.0
|
commit D
Tag 可以理解成给某一个历史节点贴上的固定标签。
11. Git Hook:把规范自动化
如果团队规定:
提交之前必须格式化代码
提交之前必须运行测试
提交之前必须检查敏感信息
提交之前必须检查 commit message
每次依靠人工检查并不可靠。
因此 Git 提供了 Hook。
Hook 可以理解为:
当某个 Git 操作发生
↓
自动执行一段程序
例如:
git commit
↓
pre-commit hook
↓
格式检查
↓
测试
↓
通过后才真正提交
这样就可以把:
“大家记得遵守这个规则”
变成:
“工具自动保证这个规则被执行”
这也是软件工程中的一个重要思想:
能自动化执行的规范,就尽量不要长期依赖人的记忆。
12. Git 的底层结构
从实现上看,Git 可以看成一个内容寻址的数据存储系统。
.git 中有几个非常重要的部分:
.git/
├── HEAD
├── index
├── objects/
└── refs/
其中最核心的是:
objects
refs
HEAD
index
13. Git 的三种核心对象
Git 主要使用三类对象组织项目历史:
blob
tree
commit
13.1 blob
blob 保存文件的内容。
例如:
hello.c
文件中的字节内容会对应一个 blob。
Blob 本身重点关注的是:
文件内容是什么
13.2 tree
tree 描述目录结构。
它记录:
文件名
权限
对应 blob
子目录对应的 tree
例如:
project/
├── README.md
├── hello.c
└── src/
└── main.c
可以抽象为:
tree(project)
├── README.md -> blob A
├── hello.c -> blob B
└── src -> tree C
└── main.c -> blob D
因此 tree 把独立的文件内容组织成了目录。
13.3 commit
Commit 指向某一个 tree,同时还保存提交相关信息。
可以粗略理解为:
commit
├── root tree
├── parent commit
├── author
├── time
└── message
于是一个 commit 就能描述:
在某一个时间点,整个项目是什么样子。
多个 commit 再通过 parent 关系连接起来:
C1 <- C2 <- C3 <- C4
最终形成项目历史。
14. Git 为什么不需要完整复制整个项目
假设版本 V1 中有:
project/
├── a.txt
├── b.txt
└── src/
├── c.c
└── d.c
V2 只修改:
b.txt
并不意味着 Git 必须把所有完全没变的内容再复制一遍。
没有变化的对象可以继续复用。
概念上类似:
V1
├── blob A
├── blob B1
└── tree SRC
修改以后:
V2
├── blob A # 复用
├── blob B2 # 新对象
└── tree SRC # 复用
因此 Git 可以同时获得:
“每一个版本都像完整快照”
和:
“没有必要重复保存完全相同的数据”
这两方面的效果。
15. Branch 本质上是什么
Branch 并不是“复制了一整套项目”。
它可以理解成:
一个指向某个 commit 的可移动指针。
例如:
A <- B <- C
↑
main
创建:
git branch dev
以后:
A <- B <- C
↑
main
↑
dev
如果在 dev 上继续提交:
A <- B <- C <- D
↑ ↑
main dev
所以创建 branch 的成本很低,因为本质上只是增加了一个引用。
16. HEAD 是什么
Git 还需要知道:
“我当前到底在哪个分支上?”
这就是 HEAD 的作用。
正常情况下,可以粗略理解为:
HEAD
↓
main
↓
commit C
因此:
HEAD -> branch -> commit
切换分支时,HEAD 指向的分支也会随之改变。
17. Merge 与冲突
假设:
A
/
C ----
\
B
两个开发者从同一个版本出发:
Developer A 修改某一行
Developer B 也修改同一行
Git 无法凭空知道:
应该保留 A
还是保留 B
还是把两者结合
于是就产生 merge conflict。
工作区中通常会出现类似的冲突标记:
<<<<<<< HEAD
当前版本
=======
另一个版本
>>>>>>> branch
这些标记的意义不是“Git 坏掉了”,而是:
Git 把自动无法决定的问题交给开发者处理,同时尽可能保留解决问题需要的信息。
解决冲突之后:
git add <file>
git commit
即可完成合并。
一个典型的 merge commit 可以拥有两个 parent:
A ----\
M
B ----/
18. 利用历史调试:blame 与 bisect
良好的提交历史不仅是为了保存代码,还能辅助调试。
git blame
git blame hello.c
可以帮助回答:
这一行是谁在哪一次提交中修改的?
这对于寻找某段代码的历史来源非常有用。
git bisect
如果已知:
旧版本:正常
当前版本:有 Bug
那么 Bug 一定是在中间某个 commit 被引入的。
可以把提交历史想象成:
good good good good bad bad bad bad
git bisect 使用类似二分查找的方法,不断选择中间版本进行测试,从而定位第一次出现问题的提交。
所以:
清晰、小粒度的 commit
会显著提高这种调试方式的价值。
19. Linear History
多人协作以后,Git 历史很容易出现大量分叉和 merge commit:
B ----\
A ---- D ---- E
C ----/
另一种选择是通过 rebase 等方式维持相对线性的历史:
A -> B -> C -> D -> E
线性历史的优点包括:
- 阅读历史比较直观;
- 更容易定位某次修改;
- 对
blame、bisect、cherry-pick等操作比较友好。
但 Linear History 并不是绝对正确的唯一方案。
真正重要的是:
团队采用明确、一致、适合自己的协作规范。
20. Git 的核心:用 Atomic Change 推进项目
把前面的内容压缩到一起,可以得到一个非常重要的软件工程思路:
一个复杂项目
↓
拆成小任务
↓
每个任务形成 Atomic Change
↓
每个 Atomic Change 形成一个 commit
↓
commit 构成项目历史
于是:
需求
↓
小任务
↓
修改
↓
测试
↓
commit
↓
下一项任务
而不是:
一次修改几百个地方
↓
项目进入混乱状态
↓
不知道哪里出了问题
版本控制真正管理的并不只是文件。
它实际上在帮助人管理:
复杂度
协作
时间
试错
21. AI Agent 时代为什么仍然要理解 Git
今天很多操作已经可以直接交给 Agent:
检查修改
生成 commit message
创建分支
处理 merge conflict
运行测试
重构代码
因此,人未必需要记住所有 Git 命令。
但仍然需要理解:
Git 能做什么
什么是好的提交
什么东西应该进入仓库
什么时候应该创建分支
什么情况下历史可以重写
如何判断 Agent 的操作是否合理
换句话说:
AI 可以替你完成操作,但工程判断仍然需要建立在正确的概念之上。
如果不知道 Git 的模型,就很难判断 Agent 做的是:
正确的软件工程操作
还是:
仅仅让当前任务暂时跑通
22. Agent 时代的软件工程分工
一种比较合理的开发方式是:
第一阶段:人把握架构
首先确定:
模块如何划分
接口是什么
状态放在哪里
哪些组件应该相互独立
数据如何流动
尤其应尽量:
降低共享状态
明确模块边界
定义稳定接口
架构设计决定了后面的开发是否容易并行。
第二阶段:Agent 完成局部任务
当边界明确以后:
Agent A -> 模块 A
Agent B -> 模块 B
Agent C -> 测试
可以同时推进。
每个 Agent 都尽量进行:
小任务
小修改
小提交
Git 因此会成为多 Agent 开发的重要基础设施。
23. 软件工程中的“品位”
课程中反复强调的一个问题是:
工程能力并不只是“能不能把程序做出来”。
两个程序都能运行,并不意味着工程质量相同。
还需要关注:
目录是否合理
README 是否清楚
commit 是否整洁
命名是否一致
文档格式是否统一
接口是否自然
项目是否容易维护
这些细节最终形成工程上的 taste(品位)。
AI 非常擅长执行任务,但人仍然需要能够判断:
什么只是“能用”
什么是真正“做得好”
24. 一个重要的学习方法:Virtual Implementation
学习一个已有系统之前,可以先问自己:
如果让我从零设计,我会怎么做?
然后再去观察真实系统:
我的方案
VS
真实方案
如果两者一样:
说明已经理解这个知识。
如果真实方案更好:
继续追问:
为什么别人这样设计?
我漏掉了什么约束?
如果自己的方案更好:
继续验证自己的方案是不是真的更好。
这种学习方式比单纯记住:
“Git 有 blob、tree、commit”
更重要。
因为真正希望建立的是:
需求
↓
推理
↓
设计
↓
实现
的能力。
25. 本节总结
这节课表面上讲的是 Git,实际上讨论的是软件工程如何管理复杂性。
最重要的几个概念可以归纳为:
软件项目 ≈ 一个不断变化的目录
Git ≈ 项目快照的持久化管理
Commit ≈ 一次有意义的 Atomic Change
Branch ≈ 指向 Commit 的可移动指针
Git History ≈ 项目演化过程的结构化记录
最终的软件工程工作流可以理解为:
需求
↓
拆分
↓
实现一个小变化
↓
检查与测试
↓
形成一个完整 commit
↓
继续下一步
Git 的价值也不仅仅是“防止代码丢失”。
它让一个复杂的软件项目能够:
- 安全试错;
- 回到过去;
- 并行开发;
- 合并修改;
- 审计历史;
- 定位问题;
- 与其他开发者或 Agent 协作。
因此,理解 Git 最好的方式,并不是背诵几十条命令,而是理解它试图解决的问题,以及这些机制为什么会自然地被设计出来。

浙公网安备 33010602011771号