从零开始的第一次提交 —— Git基本工作流
代码写完不算完,提交上去才算完。这一章我们从头到尾走一遍Git的基本工作流,让你从”会敲命令”升级到”理解每一步在做什么”。
📋 开篇自测:你已经知道多少?
git add .和git add -p有什么区别?什么场景下应该用后者?- 一条好的commit message应该包含哪些要素?
git pull和git fetch的区别是什么?- 你能说出
git diff、git diff --staged、git diff HEAD分别对比的是哪两个区域吗?
一、快递打包发货:理解Git工作流的比喻
在正式开始之前,我想用一个贯穿全章的比喻来帮你建立直觉。
想象你经营一家网上商店,每天都要处理发货。你的工作流是这样的:
- 从仓库货架上取货并检查——这是你的工作区,你在这里准备和检视商品
- 把确认要发的商品放到打包台上——这是暂存区(
git add),你挑选出这批要发的东西 - 封箱、贴单、确认发货——这是提交(
git commit),包裹密封后就有了唯一的快递单号 - 交给快递公司揽收——这是推送(
git push),把你本地的包裹送到远程仓库
每个包裹(commit)都有唯一的单号(SHA-1哈希),里面装的东西清清楚楚(变更内容),快递单上写着寄件人(作者)、时间和备注(提交信息)。
你不会把所有商品一股脑装进一个超大箱子里——那样收件人没法分辨哪些是哪个订单的。同样,一次好的提交应该只包含一个逻辑完整的改动。
🤔 想一想 在这个比喻中,“快递公司揽收”之前的所有操作都在你自己的工作台上完成。这对应Git的哪个核心设计理念?
二、git add:精心挑选要发货的商品
基本用法
# 暂存单个文件
git add index.html
# 暂存多个指定文件
git add index.html style.css app.js
# 暂存当前目录下的所有变更
git add .
# 暂存整个仓库的所有变更
git add -A
⚠️ 常见误区 “
git add .和git add -A完全不同”——在 Git 2.x 中,如果你在仓库根目录执行,git add .和git add -A的行为是完全一致的,都会暂存所有变更(包括新文件、修改和删除)。两者的区别仅在于从子目录执行时:git add .只暂存当前目录及其子目录中的变更,而git add -A始终暂存整个仓库的变更,不受当前工作目录的影响。
git add 做的事情是:把工作区中的改动复制一份到暂存区。注意,是”复制”而不是”移动”——执行 git add 后,你的工作区文件不会有任何变化。
暂存区的本质
暂存区(Staging Area,也叫 Index)并不是一个神秘的概念。它本质上就是一个文件(.git/index),记录了”下次提交时应该包含哪些文件的哪个版本”。
一个常见的困惑:如果你 git add 了一个文件之后又继续修改了它,会发生什么?
# 修改文件
echo "第一行内容" > notes.txt
git add notes.txt
# 继续修改同一个文件
echo "第二行内容" >> notes.txt
# 查看状态
git status
# Changes to be committed:
# modified: notes.txt ← 暂存区里的版本(只有第一行)
#
# Changes not staged for commit:
# modified: notes.txt ← 工作区的新改动(加了第二行)
同一个文件同时出现在”已暂存”和”未暂存”两个列表中!这是因为暂存区保存的是你执行 git add 那一刻的文件快照。之后的修改不会自动进入暂存区,你需要再次 git add 才行。
这个设计看似复杂,实则非常有用——它让你能精确控制每次提交的内容。
部分暂存:git add -p
这是一个很多人不知道但极其实用的功能。-p(patch模式)让你可以逐块选择要暂存的内容,而不是整个文件一起暂存。
假设你在一个文件里同时修了一个Bug和加了一个新功能。按照”原子提交”的原则,这应该是两次提交。git add -p 就能帮你做到:
git add -p app.js
Git会把文件的改动拆分成多个”块”(hunk),逐一询问你:
@@ -10,6 +10,8 @@ function calculateTotal(items) {
let total = 0;
for (const item of items) {
- total += item.price;
+ total += item.price * item.quantity; // 修复:之前没有乘以数量
}
return total;
}
Stage this hunk [y,n,q,a,d,s,e,?]?
常用的回答:
y(yes):暂存这一块n(no):跳过这一块s(split):把这一块拆成更小的块q(quit):退出,已选择的块会被暂存
通过这种方式,你可以把同一个文件的不同修改拆分到不同的提交中——Bug修复一个提交,新功能一个提交。干净利落。
.gitignore:告诉Git哪些文件不用管
并非所有文件都应该纳入版本控制。编译产物、依赖包、IDE配置、日志文件、包含密码的环境变量文件——这些东西不应该出现在仓库里。
在项目根目录创建 .gitignore 文件:
# 编译产物
*.class
*.o
*.pyc
__pycache__/
dist/
build/
# 依赖目录
node_modules/
vendor/
# IDE和编辑器配置
.idea/
.vscode/
*.swp
*.swo
*~
# 操作系统文件
.DS_Store
Thumbs.db
# 环境变量和密钥(非常重要!)
.env
.env.local
*.pem
*.key
# 日志文件
*.log
logs/
.gitignore 的规则很简单:
- 每行一条规则
#开头是注释*匹配任意字符**匹配任意层级的目录/结尾表示目录!开头表示取反(强制包含某个被忽略的文件)
一个实用技巧:GitHub维护了一个各种语言和框架的 .gitignore 模板仓库(https://github.com/github/gitignore),创建项目时可以直接拿来用。
⚠️ 常见误区
- 误区一:“已经被Git跟踪的文件加到.gitignore就能忽略了”。不对。
.gitignore只对尚未被跟踪(untracked)的文件生效。如果一个文件已经被提交过了,你需要先用git rm --cached 文件名把它从Git的跟踪列表中移除,然后再在.gitignore中添加规则。- 误区二:“把.env提交到仓库没关系,反正是私有仓库”。非常危险。私有仓库的权限可能会变,代码可能会被fork,硬盘可能会被别人接触。密钥和密码永远不应该出现在版本控制中,这是安全底线。
三、git commit:封箱贴单的学问
基本用法
# 提交暂存区的所有内容,附带提交信息
git commit -m "修复购物车总价计算的数量乘积问题"
# 打开编辑器写较长的提交信息(推荐用于重要提交)
git commit
# 跳过暂存区,直接提交所有已跟踪文件的修改和删除
# (注意:-a 只对已跟踪的文件生效,不会添加新的未跟踪文件)
git commit -a -m "更新配置文件"
原子提交:一次提交只做一件事
原子提交(Atomic Commit)是Git使用的黄金法则之一。每次提交应该是一个最小的、逻辑完整的改动单元。
坏的做法——一个”大杂烩”提交:
commit: 修了个Bug,顺便重构了用户模块,另外还改了下配置文件
这种提交有什么问题?如果那个Bug修复引入了新的问题需要回退,你不得不连带回退重构和配置变更。
好的做法——三个原子提交:
commit 1: fix: 修复用户登录时密码验证的边界条件
commit 2: refactor: 提取用户模块的公共校验逻辑
commit 3: chore: 更新数据库连接池的超时配置
每个提交都是独立的,可以单独回退、单独审查。
Conventional Commits规范
Conventional Commits是一种广泛采用的提交信息规范,格式如下:
<类型>[可选的作用域]: <简短描述>
[可选的正文]
[可选的脚注]
常用的类型前缀:
| 类型 | 用途 | 示例 |
|---|---|---|
feat | 新功能 | feat: 添加用户头像上传功能 |
fix | Bug修复 | fix: 修复支付金额精度丢失问题 |
docs | 文档变更 | docs: 更新API接口文档 |
style | 代码格式(不影响逻辑) | style: 统一缩进为2空格 |
refactor | 重构(不改功能不修Bug) | refactor: 重构订单状态机 |
test | 测试相关 | test: 补充支付模块单元测试 |
chore | 构建/工具/配置 | chore: 升级webpack到v5 |
写好提交信息的五条准则
- 用祈使句:写”添加用户验证”而不是”添加了用户验证”
- 首行不超过50个字符:首行是摘要,要简洁有力
- 首行和正文之间空一行:这是Git工具链的约定
- 正文说明”为什么”而非”做了什么”:代码差异已经告诉了”做了什么”,提交信息应该解释动机
- 关联Issue编号:如
fix: 修复登录超时 (#234)
一个优秀的提交信息示例:
fix: 修复并发下单时库存扣减的竞态条件
当多个用户同时下单同一商品时,之前的实现使用了非原子操作
来检查和扣减库存,导致超卖。改为使用数据库乐观锁机制,
通过版本号确保库存扣减的原子性。
性能测试显示在1000并发下,订单处理延迟增加约5ms,
在可接受范围内。
Closes #1024
🤔 想一想 去看看你手头项目最近10条提交信息。如果让一个新入职的同事阅读,他能理解每次提交做了什么吗?
四、git log:翻阅快递记录
提交之后,你可以用 git log 查看提交历史——就像翻看你的发货记录。
基本用法
# 默认输出:完整信息
git log
# commit a1b2c3d4e5f6789012345678901234567890abcd (HEAD -> main)
# Author: Zhang San <zhangsan@example.com>
# Date: Tue Mar 10 14:30:00 2026 +0800
#
# feat: 添加用户注册功能
#
# commit b2c3d4e5f67890123456789012345678901abcde
# Author: Li Si <lisi@example.com>
# Date: Mon Mar 9 09:15:00 2026 +0800
#
# fix: 修复首页加载超时问题
常用的显示格式
# 单行精简模式(日常最常用)
git log --oneline
# a1b2c3d feat: 添加用户注册功能
# b2c3d4e fix: 修复首页加载超时问题
# c3d4e5f chore: 初始化项目结构
# 图形化展示分支和合并(配合别名 git lg 使用)
git log --oneline --graph --all
# * a1b2c3d (HEAD -> main) feat: 添加用户注册功能
# * d4e5f6g Merge branch 'feature/login'
# |\
# | * e5f6g7h feat: 实现登录页面UI
# | * f6g7h8i feat: 添加登录接口调用
# |/
# * b2c3d4e fix: 修复首页加载超时问题
# * c3d4e5f chore: 初始化项目结构
过滤和搜索
# 按作者筛选
git log --author="Zhang San"
# 按时间范围筛选
git log --since="2026-03-01" --until="2026-03-10"
git log --since="2 weeks ago"
# 按提交信息关键词搜索
git log --grep="修复"
# 查看某个文件的修改历史
git log -- src/app.js
# 限制显示条数
git log -5 # 只显示最近5条
# 显示每次提交的简要统计信息
git log --stat
# a1b2c3d feat: 添加用户注册功能
# src/controllers/user.js | 45 +++++++++++++++
# src/routes/auth.js | 12 ++++
# tests/user.test.js | 38 +++++++++++++
# 3 files changed, 95 insertions(+)
组合使用的一个实用场景:你想知道同事小王上周修了哪些Bug——
git log --author="Wang" --since="1 week ago" --grep="fix" --oneline
五、git diff:验货检查
git diff 用来查看具体改了什么内容。理解它的关键在于搞清楚它对比的是哪两个东西。
三种核心用法
git diff git diff --staged
┌──────────────┐ ┌──────────────────┐
│ │ │ │
▼ │ ▼ │
┌─────────┐ ┌────┴────┐ ┌────────────┐ │
│ 工作区 │ │ 暂存区 │ │ 最近一次提交 │ │
│ │ │ │ │ (HEAD) │ │
└─────────┘ └─────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────┘
git diff HEAD
# 1. 工作区 vs 暂存区(我改了什么但还没 git add)
git diff
# 2. 暂存区 vs 最近一次提交(我 git add 了什么但还没 commit)
git diff --staged # 等价于 git diff --cached
# 3. 工作区 vs 最近一次提交(综合看所有未提交的变更)
git diff HEAD
对比两个特定提交
# 对比两个提交之间的差异
git diff a1b2c3d b2c3d4e
# 对比某个文件在两个提交之间的差异
git diff a1b2c3d b2c3d4e -- src/app.js
# 对比当前分支和另一个分支
git diff main feature/login
读懂diff输出
diff --git a/src/calculator.js b/src/calculator.js
index 8d0f1a2..3b4c5d6 100644
--- a/src/calculator.js ← 修改前的文件(a版本)
+++ b/src/calculator.js ← 修改后的文件(b版本)
@@ -8,7 +8,8 @@ function calculateTotal(items) {
let total = 0; ← 未修改的上下文行
for (const item of items) {
- total += item.price; ← 删除的行(红色)
+ total += item.price * item.quantity; ← 新增的行(绿色)
+ console.log(`小计: ${item.price * item.quantity}`); ← 新增的行
}
return total;
}
- 以
-开头的行:被删除的内容 - 以
+开头的行:新增的内容 - 没有前缀的行:未改变的上下文(帮你定位修改的位置)
@@ -8,7 +8,8 @@:表示这段差异从原文件第8行开始(共7行),在新文件中从第8行开始(共8行)
六、git status:包裹状态一览
git status 是你在Git中使用频率最高的命令之一。它告诉你当前仓库的状态——有哪些文件被改了、哪些已暂存、哪些是新文件。
git status
输出中可能出现的几种状态:
On branch main
Your branch is up to date with 'origin/main'.
Changes to be committed: # ✅ 已暂存,等待提交
(use "git restore --staged <file>..." to unstage)
modified: src/app.js
new file: src/utils/helper.js
Changes not staged for commit: # ⚡ 已修改,尚未暂存
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/config.js
Untracked files: # 🆕 新文件,Git尚未跟踪
(use "git add <file>..." to include in what will be committed)
README.md
tests/
精简模式:
git status -s
# M src/app.js ← 已暂存的修改(M在左列)
# A src/utils/helper.js ← 已暂存的新文件
# M src/config.js ← 未暂存的修改(M在右列)
# ?? README.md ← 未跟踪的文件
# MM src/index.js ← 暂存后又有新修改(两列都有M)
精简模式的两列含义:
XY 文件名
││
│└── 工作区状态
└─── 暂存区状态
M = 已修改 A = 新文件 D = 已删除 R = 已重命名 ? = 未跟踪
🤔 想一想 如果你看到
git status -s输出了MM app.js,说明什么情况?你接下来应该怎么做?
七、远程仓库:把包裹送出去
到目前为止,所有操作都在本地完成。要和团队协作,你需要把代码推送到远程仓库(GitHub、GitLab、Gitee等),也需要从远程仓库获取别人的代码。
关联远程仓库
# 添加远程仓库(通常命名为 origin)
git remote add origin https://github.com/username/my-project.git
# 查看已关联的远程仓库
git remote -v
# origin https://github.com/username/my-project.git (fetch)
# origin https://github.com/username/my-project.git (push)
# 修改远程仓库地址
git remote set-url origin git@github.com:username/my-project.git
# 删除远程仓库关联
git remote remove origin
origin 只是一个约定俗成的名字,你可以叫它任何名字。但几乎所有项目都把主远程仓库叫做 origin——就像大家都把主分支叫 main 一样。
push、pull、fetch的区别
这三个命令是与远程仓库交互的核心,搞清它们的区别至关重要。
本地仓库 远程仓库
┌──────────────┐ ┌──────────────┐
│ │ git push │ │
│ 本地提交 │ ─────────────→ │ 远程提交 │
│ │ │ │
│ │ git fetch │ │
│ 远程追踪分支 │ ←───────────── │ │
│ │ (只下载不合并) │ │
│ │ │ │
│ │ git pull │ │
│ 本地分支 │ ←───────────── │ │
│ │ (下载并合并) │ │
└──────────────┘ └──────────────┘
git push:把你的本地提交上传到远程仓库。
# 第一次推送,设置上游分支(-u 参数)
git push -u origin main
# 之后只需要
git push
git fetch:从远程仓库下载最新的提交和分支信息到本地,但不会修改你的工作区和当前分支。它只是更新你本地的”远程追踪分支”(如 origin/main)。
git fetch origin
# 然后你可以查看远程有什么新内容
git log origin/main --oneline
# 或者对比差异
git diff main origin/main
git pull:相当于 git fetch + git merge。它会下载远程的新提交,并自动合并到你当前的分支。
git pull origin main
# 等价于:
# git fetch origin
# git merge origin/main
什么时候用 fetch,什么时候用 pull?
- 日常开发快速同步:
git pull简单省事 - 想先看看远程有什么变化再决定怎么合并:
git fetch+ 手动操作更安全 - 在重要分支上操作:建议用
git fetch,心里有数再合并
⚠️ 常见误区
- 误区一:“
git pull和git fetch差不多”。差别很大。fetch只下载数据,你的本地代码纹丝不动;pull会直接修改你的当前分支。在某些情况下,pull的自动合并可能会产生你意料之外的冲突。- 误区二:“push失败就用
--force”。这是非常危险的操作——它会用你的本地版本强制覆盖远程仓库,可能把别人的提交直接抹掉。push失败通常是因为远程有你本地没有的新提交,正确做法是先pull再push。
八、SSH密钥配置
每次push/pull都要输密码非常烦。配置SSH密钥可以实现免密认证,而且比HTTPS更安全。
生成SSH密钥对
# 使用ed25519算法(推荐,比RSA更安全且密钥更短)
ssh-keygen -t ed25519 -C "your.email@example.com"
# 如果你的系统不支持ed25519,用RSA
ssh-keygen -t rsa -b 4096 -C "your.email@example.com"
执行后会有几个提示:
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/you/.ssh/id_ed25519): ← 按回车用默认路径
Enter passphrase (empty for no passphrase): ← 可选,设置密码短语增加安全性
Enter same passphrase again:
这会在 ~/.ssh/ 目录下生成两个文件:
id_ed25519:私钥,绝对不能泄露,就像你家的门钥匙id_ed25519.pub:公钥,可以放心交给GitHub/GitLab,就像你家的锁
添加公钥到GitHub
# 查看并复制公钥内容
cat ~/.ssh/id_ed25519.pub
# 输出类似:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... your.email@example.com
然后:
- 打开GitHub → Settings → SSH and GPG keys → New SSH key
- 给这个密钥起一个名字(如”我的MacBook”)
- 把刚才复制的公钥内容粘贴到Key输入框
- 点击Add SSH key
GitLab的操作类似:Settings → SSH Keys → 粘贴公钥。
验证连接
ssh -T git@github.com
# Hi username! You've successfully authenticated, but GitHub does not provide shell access.
看到上面这条消息就说明配置成功了。之后你可以用SSH地址来clone和push:
# 用SSH地址克隆(不再需要输密码)
git clone git@github.com:username/my-project.git
# 如果已有仓库,把远程地址切换为SSH
git remote set-url origin git@github.com:username/my-project.git
管理多个SSH密钥
如果你有多个Git账号(比如公司GitLab和个人GitHub),可以通过SSH配置文件来管理:
# 创建或编辑 ~/.ssh/config
# 个人GitHub
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_personal
# 公司GitLab
Host gitlab.company.com
HostName gitlab.company.com
User git
IdentityFile ~/.ssh/id_ed25519_work
九、实战:从零到推送的完整流程
理论讲够了,让我们完整地走一遍从创建项目到推送远程仓库的全过程。
第一步:初始化项目
# 创建项目目录
mkdir todo-app && cd todo-app
# 初始化Git仓库
git init
# Initialized empty Git repository in /home/you/todo-app/.git/
# 确认当前状态
git status
# On branch main
# No commits yet
# nothing to commit
第二步:创建项目文件
# 创建主程序文件
cat > app.py << 'EOF'
"""简易待办事项应用"""
def add_todo(todos, item):
"""添加一条待办事项"""
todos.append({"task": item, "done": False})
return todos
def list_todos(todos):
"""列出所有待办事项"""
for i, todo in enumerate(todos, 1):
status = "✓" if todo["done"] else "○"
print(f" {status} {i}. {todo['task']}")
if __name__ == "__main__":
my_todos = []
add_todo(my_todos, "学习Git基础")
add_todo(my_todos, "完成第一次提交")
list_todos(my_todos)
EOF
# 创建.gitignore
cat > .gitignore << 'EOF'
__pycache__/
*.pyc
.env
EOF
第三步:暂存并提交
# 查看状态
git status
# Untracked files:
# .gitignore
# app.py
# 暂存所有文件
git add .
# 确认暂存内容
git status
# Changes to be committed:
# new file: .gitignore
# new file: app.py
# 提交
git commit -m "feat: 初始化待办事项应用,实现添加和列表功能"
# [main (root-commit) 7f3a1b2] feat: 初始化待办事项应用,实现添加和列表功能
# 2 files changed, 20 insertions(+)
第四步:继续开发并提交
# 添加新功能
cat >> app.py << 'EOF'
def complete_todo(todos, index):
"""标记待办事项为已完成"""
if 0 < index <= len(todos):
todos[index - 1]["done"] = True
else:
print(f"无效的序号: {index}")
EOF
# 查看改动内容
git diff
# @@ -15,3 +15,10 @@ if __name__ == "__main__":
# ...(显示新增的函数)
# 暂存并提交
git add app.py
git commit -m "feat: 添加待办事项完成标记功能"
第五步:关联远程仓库并推送
# 先在GitHub上创建一个空仓库(不要勾选README和.gitignore)
# 然后关联远程仓库
git remote add origin git@github.com:yourname/todo-app.git
# 推送到远程仓库(-u 设置上游分支,以后直接 git push 即可)
git push -u origin main
# 确认提交历史
git log --oneline
# 8c4d5e6 feat: 添加待办事项完成标记功能
# 7f3a1b2 feat: 初始化待办事项应用,实现添加和列表功能
整个流程用一张图总结:
① git init ② 编写代码 ③ git add .
创建仓库 ──────────→ 工作区修改 ──────────→ 加入暂存区
│
⑤ git push │ ④ git commit
推送到远程 ←──────── 提交到本地仓库 ←──┘
到这里,你已经完成了Git工作流的完整闭环。之后的日常开发,就是不断重复②→③→④→⑤这个循环。
🤔 想一想 在第三步中,我们用
git add .一次性暂存了所有文件。在什么场景下,你应该避免使用git add .而是逐个文件添加?
📝 掌握度自测
-
你修改了三个文件(A、B、C),但这次只想提交文件A和B的改动,文件C的改动留到下次提交。你应该怎么操作?
-
执行以下操作后,
git status会显示什么?echo "hello" > test.txt git add test.txt echo "world" >> test.txt -
以下两条提交信息,哪条更好?为什么?
- A:
修改了user.js文件的第34行到第56行 - B:
fix: 修复未登录用户访问个人中心时的空指针异常
- A:
-
你执行
git push时收到了以下错误:! [rejected] main -> main (fetch first) error: failed to push some refs to 'origin'这是什么原因?你应该怎么解决?
-
描述
git fetch和git pull的区别。在什么场景下你更倾向于使用git fetch?
💡 自我评估
- 全部答对:你已经掌握了Git的基本工作流,可以自信地在日常开发中使用Git了。下一章我们将深入分支管理。
- 答对3-4题:基础很好,建议重点回顾
diff的三种对比方式和fetch/pull的区别。- 答对1-2题:建议跟着”实战”部分自己动手操作一遍。Git是一个实践性很强的工具,光看不练很难记住。
购买课程解锁全部内容
版本控制不翻车:Git 从基础到团队协作
¥29.90