GIT操作

前言

  • 这篇是 Git 的日常操作笔记:工作区/暂存区/仓库三层模型、增删改查、分支与合并冲突、远程协作、免密登录。
  • 本文的命令与结论在本机实测过,环境 git 2.56.0.windows.4(Windows,Git Bash)——从 2.54 升上来之后整篇重跑了一遍:冲突流程、UU 状态、git commit -am 的边界、reset --soft/--mixed 的状态码、Windows 的 CRLF 警告这些输出都没变;变的是「push 被拒的标签」和「没配身份时 Git 的行为」,另外 2.56 多了几个新入口,见文末「版本差异速记」。所有输出都原样贴在对应命令下面。

为什么需要版本控制

  • 黑暗时期:定稿 → 定稿2 → 定稿19.04.12 → 最终版 → 最最终版 → 打死不改版。没有任何机制能回答「这一行是谁在什么时候改的、改之前是什么」。
  • 集中式(CVS、SVN):仓库只有一份在服务器上,提交必须有网;历史记录也在服务端。
  • 分布式(Git、Mercurial):每个克隆都是完整仓库(含全部历史),离线也能提交、能看日志、能开分支;2008 年 GitHub 上线后 Git 成为事实标准。
  • Git 的代价是概念多(工作区/暂存区/HEAD/索引/引用/reflog),但一旦建立模型,误操作几乎都能救回来。

安装与配置

1
2
3
4
5
6
7
8
# Debian / Ubuntu
sudo apt install git
# macOS
brew install git
# Windows:官网安装器,或
winget install Git.Git

git --version # 实测:git version 2.56.0.windows.4

身份与常用配置(用户名和邮箱要显式配——不配的话 Git 会拿「用户名@主机名」凑一个身份,提交照样成功但作者信息是错的,实测见本节末尾):

1
2
3
4
5
6
7
8
9
git config --global user.name  "Your Name"
git config --global user.email "you@example.com"
git config --global core.editor vim
git config --global init.defaultBranch main # 新仓库默认分支名,不再写死 master
git config --global alias.st status # 别名:git st

git config -l # 看全部生效配置(含来源文件)
git config --global -l # 只看全局
git config --show-origin --get user.name # 连配置文件路径一起看
  • 作用域三层:--system(全机器)< --global(当前用户,~/.gitconfig)< --local(当前仓库,.git/config,默认就是这个)。
  • 想撤销某项配置:git config --global --unset user.email。

三层模型:工作区 / 暂存区 / 仓库

Git 的三层模型:工作区 --git add--> 暂存区 --git commit--> 本地仓库 --git push/pull--> 远程仓库;回退用 restore / reset / clean,已推送的用 revert

  • 为什么要有暂存区:让你从一堆改动里挑出「这次提交要包含什么」。没有暂存区的模型(如早期的 svn commit)只能整体提交。
  • git status 就是在告诉你:哪些改动在工作区、哪些已进暂存区、哪些是从未被跟踪的文件。

基本操作

1
2
3
4
5
6
7
8
9
10
11
12
13
git init -b main              # 新建仓库,并指定初始分支名(实测可用;也支持 --initial-branch=main)
git status # 看当前状态;-s 紧凑输出,-sb 顺带显示与上游的差异
git add 文件... # 指定文件进暂存区
git add . # 当前目录及子目录下所有改动(含新增,但不含被 .gitignore 排除的)
git add -p # 逐块(hunk)挑选要暂存的改动,写干净提交的神器
git commit -m "说明" # 提交;不带 -m 会打开编辑器
git commit --amend # 改最近一次提交(补内容/改说明),会换 commit 号
git log --oneline --graph --decorate -n 10
git diff # 工作区 vs 暂存区
git diff --staged # 暂存区 vs 最近一次提交
git show HEAD # 看某次提交的内容
git restore 文件 # 丢弃工作区的修改(未 add 之前)
git restore --staged 文件 # 把已暂存的改动退回工作区(不会丢内容)

实测两个细节:

1
2
3
4
5
6
7
8
# 1) git commit -am 只包含「已跟踪」的文件,新出现的未跟踪文件不会被顺手提交
$ echo a > t.txt && git add t.txt && git commit -m c1
$ echo b >> t.txt && echo x > u.txt && git commit -am "只提交已跟踪"
$ git show --stat --oneline HEAD
t.txt | 1 +
1 file changed, 1 insertion(+)
$ git status --short
?? u.txt # 新文件仍在外面,得显式 git add
1
2
3
4
5
6
7
# 2) git status -s 的两位状态码
M file # 工作区改了、没暂存
M file # 已暂存
A file # 新增并已暂存
?? file # 未跟踪
UU file # 双方修改冲突(合并中)
D file # 已暂存的删除

git log 常用参数:--oneline、-n 10、--graph、--decorate、--author="张三"、-- 路径(只看某个文件的历史)、-p(带 diff)、git log HEAD..origin/main(我落后了哪些提交)。

分支

1
2
3
4
5
6
7
8
9
10
11
12
13
git branch                       # 列出本地分支,当前分支前有 *
git branch -r # 远程分支
git branch -a # 全部
git branch -vv # 带最新提交与上游对应关系(查「我的分支跟踪了谁」最好用)
git switch -c dev # 建分支并切过去(新写法)
git checkout -b dev # 同上(老写法,仍然可用)
git switch main # 切分支
git merge dev # 把 dev 合并进当前分支
git rebase dev # 变基:把当前分支的提交「重放」到 dev 之上
git branch -d dev # 删除已合并的分支
git branch -D dev # 强制删除(可能有未合并内容)
git branch --delete-merged <上游模式> # 2.56 新增:按 help 的说法是「删掉已合并、且上游匹配该模式的分支」
git diff main...dev # 只看分支间的差异(三个点:从共同祖先算起)

merge 与 rebase 的取舍:merge 保留真实的分叉历史(多一个合并提交),rebase 得到一条直线历史但会改写提交号。已经推送出去、别人也在用的分支不要 rebase。

一次真实的合并(本机实测,main 与 dev 各有提交):

1
2
3
4
5
6
7
$ git log --graph --oneline
* 5c166d8 merge dev
|\
| * 3e28f7d d1
* | bcfe90c m1
|/
* 1bda0c1 c1

合并冲突:手工全流程

冲突不神秘:同一个文件的同一区域,两边都改了,Git 不敢自作主张。实测复现:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git checkout -b dev && echo dev > f.txt && git commit -am d1
$ git checkout main && echo main > f.txt && git commit -am m1
$ git merge dev
Auto-merging f.txt
CONFLICT (content): Merge conflict in f.txt
Automatic merge failed; fix conflicts and then commit the result.

$ git status -s
UU f.txt
$ git diff --name-only --diff-filter=U # 只列出冲突文件,脚本里很好用
f.txt
$ grep -c '<<<<<<<\|=======\|>>>>>>>' f.txt
3

文件里会长这样:

1
2
3
4
5
<<<<<<< HEAD
main 分支的内容
=======
dev 分支的内容
>>>>>>> dev

解决步骤(没有自动解法,只能人来决定):

  1. git status / git diff --name-only --diff-filter=U 确定冲突文件清单。
  2. 打开文件,删掉 <<<<<<<、=======、>>>>>>> 三行,留下取舍后的内容(两边都要就都留)。
  3. git add 冲突文件 表示「这块我已经处理完了」。2.56 起可以不用点名文件:git add --resolved 一次把所有「已解决」的跟踪文件标记为解决。实测:解决完 f.txt 后跑 git add --resolved,git status -s 从 UU f.txt 变成 M f.txt,再 git commit --no-edit 就得到 [main 630ad11] Merge branch 'dev'。
  4. git commit(不用再写 -m,Git 会准备好合并说明)完成合并。
  5. 想中途放弃:git merge --abort 回到合并前状态(git rebase --abort 同理)。

顺带:git mergetool 可以调外部三方合并工具(VS Code / Beyond Compare 等);git config --global merge.tool vscode。

撤销与回退(按「破坏力」从小到大)

场景 命令 破坏力
还没 add,想丢工作区改动 git restore 文件 丢这部分改动(不可恢复)
已 add,想退回工作区 git restore --staged 文件 安全,内容还在
已提交,想改最后一次 git commit --amend 换提交号;推送过就别 amend
撤销最近一次提交,保留改动 git reset --soft HEAD~1 实测:改动回到暂存区(git status -s 显示 M )
撤销提交并退回工作区 git reset HEAD~1(默认 --mixed) 实测:git status -s 变成 M(未暂存)
撤销提交并丢弃改动 git reset --hard HEAD~1 危险:工作区内容一起没了
删掉未跟踪的文件 git clean -nd 先看,再 git clean -fd 危险:这些文件没进过仓库,救不回
已推送的错误提交 git revert 提交号 安全:造一个反向提交,历史不改写
  • 兜底神器:git reflog。只要提交过(哪怕分支删了、reset --hard 了),HEAD 的移动记录还在本地 90 天,git reflog 找到旧提交号再 git reset --hard <号> 就能捞回来。
  • 记住一条:改写历史(rebase / amend / reset --hard)只在自己没推送的分支上做。
  • 2.56 新增了 git history 这个入口,专门用来动「中间某一个提交」,不用再进交互式 rebase。本地 help 给的用法是 drop / fixup / reword / split 四个子命令,都带 --dry-run 和 --update-refs=(branches|head)、--empty=(drop|keep|abort)。实测在三个提交的仓库里:
1
2
3
4
5
6
7
8
9
10
11
12
$ git log --oneline
2ce9b10 c3
9ddaa56 c2
5066476 c1
$ git history drop 9ddaa56 --dry-run
update refs/heads/main 6dc4cf36ccdce438a4592ed42cb25352998535a9 2ce9b100302a589d515e9cbfb8dfe3946b96f844
$ git history drop 9ddaa56
$ git log --oneline
6dc4cf3 c3
5066476 c1
$ ls
h1.txt h3.txt # 被 drop 的 c2 带进来的 h2.txt 也没了;git status -s 是干净的

它和 git revert 的分工:revert 造一个反向提交、历史不改写(适合已推送的);history drop 直接把那个提交从线上抹掉、后面的提交全部换号(只在自己没推送的分支上用)。

团队协作分支模型

  • main(原笔记叫 master):随时可发布的稳定分支,只接受合并,不直接提交。
  • develop:日常集成分支,功能分支合回这里。
  • feature/xxx:一人一分支(或一需求一分支),从 develop 切出,做完合回。
  • hotfix/xxx:从 main 切出的紧急修复,修完同时合回 main 与 develop。
  • release/x.y:发版冻结分支,只改 bug 不加功能。

小团队可以简化成「main + 短命功能分支 + PR 评审」。分支不是越多越好,长命分支就是风险(越晚合并冲突越大)。

远程仓库

1
2
3
4
5
6
7
8
9
10
# A) 本地已有仓库,推到新建的远程仓库
git remote add origin https://github.com/you/repo.git
git remote -v # 看已配置的远程(fetch/push 两个地址)
git push -u origin main # 第一次推送并建立跟踪关系(-u = --set-upstream)
git push # 之后再推就不用写分支了
git pull # 拉取 + 合并
git fetch origin # 只拉不看(更安全),再自己 git log HEAD..origin/main
git pull --rebase # 拉取后变基,避免多余的合并提交
git branch -vv # 检查本地分支跟哪个远程分支绑定
git remote set-url origin git@github.com:you/repo.git # 换地址(HTTP 改 SSH 很常用)
1
2
3
4
5
# B) 从远程开始(clone 自带 origin 与跟踪关系,所以之后 git push 直接可用)
git clone https://github.com/you/repo.git
cd repo && git switch -c feature/login
# 改完提交
git push -u origin feature/login
  • 推送前必须先拉:被拒时 Git 给的不是同一种标签,实测(本机临时裸仓库当远程)两种:
    • 远程有你本地没有的提交 → ! [rejected] main -> main (fetch first),hint 是「Updates were rejected because the remote contains work that you do not have locally」。这种用 git pull --rebase 或 git fetch + git rebase origin/main 解决后再推。
    • 你本地把历史回退了(比如 reset --hard 之后)→ ! [rejected] main -> main (non-fast-forward),hint 是「the tip of your current branch is behind its remote counterpart」。老教程里常说的 non-fast-forward 只对应第二种,别以为被拒就一定打印它。
  • -u 只需要在第一次建立跟踪时加。
  • 想删掉远程分支:git push origin --delete 分支名;清理本地失效的远程引用:git remote prune origin。

免密登录(SSH)

1
2
3
4
ssh-keygen -t ed25519 -C "you@example.com"     # 新建议用 ed25519;rsa 也可以(-t rsa -b 4096)
# 一路回车即可,passphrase 是可选项
cat ~/.ssh/id_ed25519.pub # 这一整行内容贴到平台的 SSH Keys 设置里
ssh -T git@github.com # 验证:出现 Hi xxx! You've successfully authenticated 即成功
  • 平台侧:GitHub/GitLab/Gitee 都是「Settings → SSH Keys」,贴公钥(.pub),私钥永远不外传。
  • 仓库地址要从 https:// 换成 git@github.com:user/repo.git(或 git remote set-url origin ...)。
  • 有多个账号/密钥时用 ~/.ssh/config:
1
2
3
4
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
  • HTTPS 路线的免密替代:git config --global credential.helper manager(Windows 版 Git 默认装了 Git Credential Manager,会弹浏览器授权);Linux 上可用 store(明文保存,注意权限)或 cache --timeout=3600。

换行符与文件属性(Windows 上必踩)

实测在 Windows 上每次 git add 都有这句提示:

1
warning: in the working copy of 'new.txt', LF will be replaced by CRLF the next time Git touches it

原因:Windows 习惯 CRLF,Git 仓库内统一存 LF,检出时按配置转换。三种做法:

1
2
git config --global core.autocrlf input      # 推荐(Windows 上也建议):仓库内 LF,检出也不转
git config --global core.autocrlf true # Windows 传统做法:检出转 CRLF、提交转 LF

更稳的是在仓库里放 .gitattributes 明确声明,不受各人本地配置影响:

1
2
3
4
* text=auto eol=lf
*.png binary
*.jar binary
*.sh text eol=lf
  • 一旦仓库里混进了「有人 CRLF 有人 LF」,就会出现「什么都没改但整文件都显示变更」的恐怖 diff。此时统一改成 LF,再 git add --renormalize . 重建一次。
  • 这条对本项目(Hexo 文章要求干净 CRLF) 同样相关:Markdown 源文件的换行策略要固定,并在 .gitattributes 里写清楚,否则不同机器编辑会把全文标成修改。

忽略文件与日常卫生

1
2
3
4
5
6
7
# 写在仓库根目录的 .gitignore
# 三个作用范围:仓库根 .gitignore(提交共享) / .git/info/exclude(只对自己) / core.excludesFile(全局)
node_modules/
dist/
__pycache__/
*.log
.env # 密码、密钥永远别进仓库
  • 已经误提交的文件:git rm --cached 路径 后提交(历史里还在,含密钥的话必须换密钥或用 git filter-repo 清洗历史)。
  • git clean -nd 先列出会删什么,别一上来就 -fd。
  • 提交信息规范(feat/fix/docs/refactor/test/chore(scope): 描述)本身不是 Git 的功能,但团队协作收益极大——看看本项目 git log 里的 docs(python): …、feat(search): … 就是这个风格。

Git 原理小抄

  • 对象库:.git/objects 里只有三种东西——blob(文件内容)、tree(目录条目)、commit(指向一个 tree + 父提交 + 作者/说明)。都是内容寻址(SHA 命名),所以改一个字节 → 哈希变 → 是另一个对象。
  • 引用(ref):refs/heads/main 只是「某个 commit 号」的一行文本,所以建分支/切分支轻如鸿毛(改文件,不复制数据)。
  • HEAD:指向「当前分支」的指针;git checkout 提交号 会进入「游离 HEAD」。
  • 索引(index):就是暂存区,一份「下一次提交长什么样」的清单。
  • tag:
    • git tag v1.0(轻量)或 -a(带说明,是对象)。
    • git push origin v1.0 要显式推标签。
    • 发布版本用带注释的标签。
  • 因为历史是「父提交」串起来的单向链,所以改写任何一个提交,后面全部换号——这就是 rebase 会重写历史的底层原因。

版本差异速记(2.54 → 2.56 实测)

升级 Git 之后重跑本篇命令,实际差别只有这几处,其余输出(UU 状态、冲突三行提示、git commit -am 的边界、reset --soft/--mixed 的状态码、CRLF 警告文案、合并提交图形状)都一样。

  • push 被拒的标签分两种:常见协作场景是 (fetch first),本地回退历史才是 (non-fast-forward),详见「远程仓库」一节。
  • 没配身份不再必然报错:2.56 实测会自动凑一个 用户名@主机名 提交成功,只打印警告;只有身份为空/猜不出来时才 *** Please tell me who you are.。
  • git init 不再打印分支名提示:实测 2.56 只输出一行 Initialized empty Git repository in <路径>/plain/.git/,早期那句 hint: Using 'master' as the name for the initial branch... 已经不出现,默认名直接取 init.defaultBranch(本机没配全局,Git for Windows 的系统级配置里写的是 master,实测 git init 后 git branch --show-current 就是 master)。想要 main 就显式 git init -b main。
  • 新增入口:git add --resolved(冲突解决后批量标记)、git history drop|fixup|reword|split(改单个提交)、git branch --delete-merged、git refs 的 list/create/exists/optimize/verify/migrate 子命令。其中 git branch --delete-merged 我在临时仓库里试了「分支已合进当前分支」「上游匹配 origin/feature/*」「远程分支已删并 fetch --prune(branch -vv 显示 [origin/feature/one: gone])」这几种状态,加不加 --dry-run 都没删掉任何分支,所以本篇不给它写示例——要用先 git branch -h 看清条件。
  • 提示语可以关:新版的 hint 里会直接告诉你开关,比如合并冲突的提示末尾是 hint: Disable this message with "git config set advice.mergeConflict false";advice.* 是一族开关,别用 --quiet 之类去猜。
  • 仍然可用的老写法:git checkout -b、git branch -d/-D、git stash/pop、git blame、git log -L :函数名:文件、git reflog + git reset --hard HEAD@{1} 捞提交,在 2.56 上全部实测通过。

常见坑速查

  • 提交被拒绝:*** Please tell me who you are. → 没配 user.name / user.email,并且 Git 连「用户名@主机名」都猜不出来(实测最后一行是 fatal: empty ident name (for <>) not allowed)。注意它不总是报错:2.56 实测在完全没有身份配置时会直接凑一个身份提交成功,只在输出里打印 Your name and email address were configured automatically based on your username and hostname.,等你推到 GitHub 上才发现作者头像挂错了人——所以别等报错,第一次用就把两条 git config --global 配好。
  • push 被拒 non-fast-forward → 远程比你新,先 git pull --rebase 再推。
  • git add . 加进了 .env / 大文件 → 立刻 git rm --cached,并写进 .gitignore;已经提交过的要清洗历史。
  • 文件名以 - 开头时 git add 报错 → 实测 git add -p.txt 报的是 error: unknown switch `.'(它把 -p.txt 当成选项了),写成 git add -- -p.txt 就正常,git status -s 里是 A -p.txt。任何以 - 开头的路径都用 -- 隔开。
  • 改了一半想切分支,git switch 报错会被覆盖 → git stash 收起来,切回来 git stash pop。
  • 删掉分支后发现工作没了 → git reflog 找旧提交号,git branch 救回来 <号>。
  • 整文件都显示改动但其实没改 → 换行符问题,按上一节统一到 LF。
  • git reset --hard 手滑 → 还在 git reflog 里,能救;git clean -fd 掉的未跟踪文件救不回。
  • 合并没有生效?分支还是旧的 → git branch -vv 看是否跟踪了错误的上游;git log --graph 确认合并方向。
  • 想看某个函数是谁改的 → git blame 文件、git log -L :函数名:文件。
  • 想临时把某次提交的内容拿来用 → git cherry-pick 提交号。

练习

  • 从零建仓库:init → 写两个文件 → add/commit → 建 dev 分支改同一文件 → merge 制造冲突 → 手工解决 → 用 git log --graph --oneline 看形状。
  • 把「撤销三连」实测一遍并记录 git status -s 的差异:reset --soft HEAD~1、reset HEAD~1、reset --hard HEAD~1;然后用 git reflog + git reset --hard <号> 把提交救回来。
  • 配一个 .gitignore 并验证:新建 debug.log 后 git status 不再显示它。
  • 用自己的 GitHub 建空仓库,走一次「remote add + push -u」,再走一次「clone → 改 → push」;然后换配置 SSH 公钥,把 https 地址改成 git@。
  • 给本仓库补一个 .gitattributes(统一 eol=lf),并解释为什么它比每个人各自配 core.autocrlf 更可靠。

参考资料

本文结束 感谢您的阅读
正在加载今日诗词....