跳到主要内容
预计阅读 40 分钟

从零开始的第一次提交 —— Git基本工作流

代码写完不算完,提交上去才算完。这一章我们从头到尾走一遍Git的基本工作流,让你从”会敲命令”升级到”理解每一步在做什么”。

📋 开篇自测:你已经知道多少?

  1. git add .git add -p 有什么区别?什么场景下应该用后者?
  2. 一条好的commit message应该包含哪些要素?
  3. git pullgit fetch 的区别是什么?
  4. 你能说出 git diffgit diff --stagedgit diff HEAD 分别对比的是哪两个区域吗?

一、快递打包发货:理解Git工作流的比喻

在正式开始之前,我想用一个贯穿全章的比喻来帮你建立直觉。

想象你经营一家网上商店,每天都要处理发货。你的工作流是这样的:

  1. 从仓库货架上取货并检查——这是你的工作区,你在这里准备和检视商品
  2. 把确认要发的商品放到打包台上——这是暂存区git add),你挑选出这批要发的东西
  3. 封箱、贴单、确认发货——这是提交git commit),包裹密封后就有了唯一的快递单号
  4. 交给快递公司揽收——这是推送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: 添加用户头像上传功能
fixBug修复fix: 修复支付金额精度丢失问题
docs文档变更docs: 更新API接口文档
style代码格式(不影响逻辑)style: 统一缩进为2空格
refactor重构(不改功能不修Bug)refactor: 重构订单状态机
test测试相关test: 补充支付模块单元测试
chore构建/工具/配置chore: 升级webpack到v5

写好提交信息的五条准则

  1. 用祈使句:写”添加用户验证”而不是”添加了用户验证”
  2. 首行不超过50个字符:首行是摘要,要简洁有力
  3. 首行和正文之间空一行:这是Git工具链的约定
  4. 正文说明”为什么”而非”做了什么”:代码差异已经告诉了”做了什么”,提交信息应该解释动机
  5. 关联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 pullgit fetch 差不多”。差别很大。fetch 只下载数据,你的本地代码纹丝不动;pull 会直接修改你的当前分支。在某些情况下,pull 的自动合并可能会产生你意料之外的冲突。
  • 误区二:“push失败就用 --force。这是非常危险的操作——它会用你的本地版本强制覆盖远程仓库,可能把别人的提交直接抹掉。push失败通常是因为远程有你本地没有的新提交,正确做法是先 pullpush

八、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

然后:

  1. 打开GitHub → Settings → SSH and GPG keys → New SSH key
  2. 给这个密钥起一个名字(如”我的MacBook”)
  3. 把刚才复制的公钥内容粘贴到Key输入框
  4. 点击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 . 而是逐个文件添加?


📝 掌握度自测

  1. 你修改了三个文件(A、B、C),但这次只想提交文件A和B的改动,文件C的改动留到下次提交。你应该怎么操作?

  2. 执行以下操作后,git status 会显示什么?

    echo "hello" > test.txt
    git add test.txt
    echo "world" >> test.txt
  3. 以下两条提交信息,哪条更好?为什么?

    • A: 修改了user.js文件的第34行到第56行
    • B: fix: 修复未登录用户访问个人中心时的空指针异常
  4. 你执行 git push 时收到了以下错误:

    ! [rejected] main -> main (fetch first)
    error: failed to push some refs to 'origin'

    这是什么原因?你应该怎么解决?

  5. 描述 git fetchgit pull 的区别。在什么场景下你更倾向于使用 git fetch

💡 自我评估

  • 全部答对:你已经掌握了Git的基本工作流,可以自信地在日常开发中使用Git了。下一章我们将深入分支管理。
  • 答对3-4题:基础很好,建议重点回顾 diff 的三种对比方式和 fetch/pull 的区别。
  • 答对1-2题:建议跟着”实战”部分自己动手操作一遍。Git是一个实践性很强的工具,光看不练很难记住。

购买课程解锁全部内容

版本控制不翻车:Git 从基础到团队协作

¥29.90