Git 命令容易记混,往往是因为没有先分清它正在改哪份内容。文件已经保存、已经 add、已经 commit,是三个不同的位置。沿着一次单机工作流走下去,撤回和恢复也就不必靠猜。
工作区、暂存区与当前提交#
工作区是编辑器正在修改的文件;暂存区也叫 index,用来组织下一次提交;HEAD 通常通过当前分支指向最近一次提交。提交保存的是一份目录树快照及其元数据,并记录父提交关系。它不是把编辑器从打开到关闭的操作逐条录下来。
例如,先把文件从 A 改成 B,执行 add,再改成 C:工作区是 C,暂存区仍是 B,当前提交还是 A。此时直接 commit,提交进去的是 B。一次 add 不会把文件变成持续跟踪后续编辑的“待提交直播”。
从对象关系看,文件内容由 blob 表示,tree 组织路径,commit 指向树并连接历史。分支是可移动的引用,而不是复制出来的一整份目录。日常操作不必直接改这些内部对象,但这能解释为什么移动分支指针、改工作区文件和删除历史对象并不是同一个动作。
从一个新仓库开始#
第一次提交前,先给提交记录配置作者身份。下面是在当前仓库设置的示例;若确实希望作为所有仓库的默认值,才加 --global。示例名字和邮箱应换成你愿意出现在提交记录中的信息。
git init
git config user.name "Example Author"
git config user.email "author@example.com"
git config --get user.name
git config --get user.email
git status初始化不会自动把目录里的文件全部纳入版本管理。新文件会先显示为 untracked;只有把内容放进暂存区并提交,它才进入历史。已经被追踪的文件,也可能同时具有已暂存和未暂存的修改。
add 的单位可以比文件更小#
git add 把执行时选中的内容状态更新到暂存区。它既可以记录新增或修改,也可以记录删除。下面几种写法的范围不同,不必顺次执行:[1]
| 命令 | 作用范围 |
|---|---|
git add -- notes.md | 只更新这个路径的暂存状态;-- 分开选项与路径 |
git add . | 当前目录及其子目录;在仓库子目录执行时不等于整个仓库 |
git add -u | 未给路径时,更新整个工作树中已追踪文件的修改和删除,不加入新文件 |
git add -p -- main.c | 交互选择这个文件中的修改块 |
如果同一文件同时有错误修复和重构,按文件提交就会把两种目的绑在一起。add -p 允许逐块选择:y 接受当前块,n 跳过,s 尝试拆分,q 结束选择。不是每块都能继续拆开;退出也不会撤销此前已经接受的块。
下面的流程假设两类修改可以分开选择。第一轮只选修复,再检查并提交;第二轮选重构。每次提交前都读一遍实际暂存内容,不能只凭刚才按了几个 y 判断。
git add -p -- main.c
git diff --cached
git commit -m "fix: correct boundary check"
git add -p -- main.c
git diff --cached
git commit -m "refactor: simplify parser"暂存区的价值就在这里:它让一次提交围绕一个可解释的目的组织,不要求一次性收走工作目录里的所有变化。
用 diff 看清三个位置之间的差别#
| 命令 | 比较对象 | 适合回答的问题 |
|---|---|---|
git diff | 工作区 ↔ 暂存区 | 还有什么修改没有暂存? |
git diff --cached | 暂存区 ↔ HEAD | 下一次普通提交准备带走什么? |
git diff HEAD | 工作区 ↔ HEAD | 已追踪文件相对当前提交总共变了什么? |
普通 diff 不会把 untracked 文件的内容当作已追踪差异展示。因此检查提交范围时,git status 和 diff 要一起看:前者列状态,后者读内容。首次提交还没有 HEAD 时,也不要照搬所有依赖 HEAD 的比较方式。
查看已有历史时,可以按问题选输出:简图看分支与提交关系,stat 看文件规模,patch 看具体内容。--all 表示显示各引用可达的历史,不是承诺找回所有曾经出现过、后来已不可达的提交。
git log --oneline --graph --all
git log --stat -5
git log -p -1提交与 amend:生成记录,或替换当前记录#
普通 git commit 用暂存区生成新提交,并把当前分支往前移。提交说明最好交代为什么改,以及改完有什么行为变化;以后排错时,“update” 很难提供线索。
若刚提交后发现漏了一处,可以把补充修改暂存,再使用 --amend。它生成替代当前末端提交的新提交,并不是在原对象上打补丁;提交标识会改变。--no-edit 只表示沿用原说明,不表示沿用原提交对象。[2]
git add -- notes.md
git diff --cached
git commit --amend --no-edit上面的例子适用于仍由自己整理的末端提交。若提交已经被其他人基于它继续工作,改写历史会带来同步问题,应按协作约定选择新增修正提交等方式。还要留意暂存区里是否已有其他内容:amend 同样会把它们带进去。
restore:先说清来源和目的地#
“撤回修改”至少包含两种需求:保留文件编辑,只撤掉暂存;或者真的丢弃工作区编辑。默认来源不同,后果就不同。下面三行是互相独立的选择,不是一套连续执行的清理流程。[3]
# 取消暂存:HEAD → 暂存区,保留工作区编辑
git restore --staged -- notes.md
# 丢弃未暂存编辑:暂存区 → 工作区
git restore -- notes.md
# 明确让两处都回到 HEAD:会丢弃这个路径的本地修改
git restore --source=HEAD --staged --worktree -- notes.md回到前面的 A、B、C 例子:执行不带选项的 restore 后,工作区回到 B,不是 A,因为默认来源是暂存区。指定 --staged 时,默认来源才是 HEAD。这也是为什么“restore 就是回到上次提交”不够准确。
这些引用 HEAD 的例子假设仓库已有提交。对相对 HEAD 新增的文件取消暂存后,它会重新成为 untracked,工作区文件仍在;不要把取消暂存理解为删除磁盘文件。真正会覆盖内容的操作,应先通过 diff 确认范围。
reset:移动末端,以及决定哪些副本跟着动#
git reset <mode> <commit> 与按路径重置暂存区的形式需要分开看。这里讨论前一种:在普通分支上,它把当前分支移到目标提交,模式决定暂存区与工作区是否一起更新。
| 模式 | 当前分支 / HEAD | 暂存区 | 工作区 |
|---|---|---|---|
--soft | 移动到目标提交 | 保留 | 保留 |
--mixed(默认) | 移动到目标提交 | 重置到目标 | 保留 |
--hard | 移动到目标提交 | 重置到目标 | 重置已追踪内容,会丢弃本地修改 |
例如目标为 HEAD~1 时,soft 适合把末次提交的改动保留为待提交内容;mixed 保留在工作区,重新挑选暂存。它们要求当前提交确实有父提交。hard 则是破坏性选择:不仅会丢掉相关已追踪文件的本地修改,挡住写入路径的未追踪文件或目录也可能被覆盖、删除;但它又不是一个“清空所有 untracked 文件”的通用命令。[4]
同样,git reset --hard origin/main 不会自动从远端获取最新内容;它使用本地已有的远端跟踪引用,并移动当前分支。把它当作“随便同步一下”很容易改错对象。
误移提交指针后,可以先通过 reflog 查找本地记录,再给找到的提交建立恢复分支,让它重新有明确引用。这里的提交标识需替换为实际核对过的值。
git reflog
git branch recovery/saved-tip <commit-sha>reflog 不是无限期备份,也不会凭空恢复从未提交过的工作区编辑。回收策略、时间和对象是否仍存在都会影响可恢复性。发现误操作后先停下继续改写,保住已找到的提交,比反复试 hard 更可靠。[5]
忽略文件,也要分清共享与本地#
.gitignore 可以提交,适合团队共同排除构建产物;.git/info/exclude 留在当前克隆里,适合个人辅助文件。它们主要控制未追踪文件的发现,不会让已经追踪的文件突然停止记录变化。[6]
# .gitignore 示例
*.log
/build/
/node_modules/
!important.log开头的斜线把规则限定到这份 ignore 文件所在目录;末尾斜线表示目录。! 用于例外,但若父目录整体被排除,不能只写一条子文件例外就期待 Git 自动深入被忽略的目录寻找它。
文件为什么没有出现在 status 中,可以先问规则本身,而不是盲目添加更多通配符:
git check-ignore -v -- docs/development-plan.local.md它会在匹配时说明规则来源与行号。忽略规则还不是隐私清理工具:若敏感内容已经进入历史,后来添加 ignore 不会移除旧提交中的内容。回到日常流程,提交前始终确认状态、检查暂存差异,再记录一个边界清楚的快照。
参考与延伸
根据技术笔记重新整理,示例使用通用名称。