前言
这篇是 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。
三层模型:工作区 / 暂存区 / 仓库
为什么要有暂存区 :让你从一堆改动里挑出「这次提交要包含什么」。没有暂存区的模型(如早期的 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
解决步骤(没有自动解法,只能人来决定 ):
git status / git diff --name-only --diff-filter=U 确定冲突文件清单。
打开文件,删掉 <<<<<<<、=======、>>>>>>> 三行,留下取舍后的内容(两边都要就都留)。
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'。
git commit(不用再写 -m,Git 会准备好合并说明)完成合并。
想中途放弃: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 更可靠。
参考资料