大四上 生成式软件工程 第三课:软件仓库管理:从“作业 V1、V2”到 Git 20260915

软件仓库管理:从“作业 V1、V2”到 Git

(AI辅助生成)

南京大学《生成式软件工程》课程笔记
主题:软件仓库管理、Git、软件工程 Best Practice 与 Agent 时代的开发方式

1. 从软件工程项目开始

开始一个软件工程项目,最直观的形式就是创建一个目录,然后在其中逐渐加入:

  • 源代码
  • 文档
  • 配置文件
  • 测试
  • 构建脚本
  • 其他与项目相关的资源

从这个角度看,可以把一个软件项目理解为一个不断变化的目录。

软件本身也不仅仅是“代码”。更抽象地说,软件是现实世界中的需求在信息世界中的实现。代码、文档、配置、测试等内容共同构成了这个实现。

因此,软件工程中的一个核心问题就变成了:

如何可靠地管理这个不断变化的项目目录?


2. 为什么需要版本控制

最原始的版本管理方法其实很简单:

作业/
作业_v2/
作业_v3/
作业_最终版/
作业_最终版2/

这种方法本质上已经包含了版本控制的需求:

  1. 保存某一时刻的项目状态;
  2. 查看以前的版本;
  3. 在修改失败后恢复;
  4. 比较两个版本发生了什么变化。

如果进一步要求:

列出所有历史版本
切换到任意一个历史版本
比较不同版本
让多个人同时修改
把不同人的修改合并起来

那么就会逐渐推导出一个完整的版本控制系统。

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

线性历史的优点包括:

  • 阅读历史比较直观;
  • 更容易定位某次修改;
  • blamebisectcherry-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 最好的方式,并不是背诵几十条命令,而是理解它试图解决的问题,以及这些机制为什么会自然地被设计出来。

posted @ 2026-09-15 21:37  陆舟LandBoat  阅读(7)  评论(0)    收藏  举报