Git: 使用git进行版本管理

使用Git进行版本管理

介绍

  • Git是一个分布式版本管理工具,它诞生的故事很有意思->Git的历史
  • 版本控制器:当我们需要修改代码但又担心无法回退时,我们需要创建副本。当修改次数多、修改规模大时,我们很难记住这些副本实现了什么功能或是修复了哪些bug,也不知道改动了哪里,而这只是一人修改的情况,如果是一个需要互相沟通的团队呢?因此,版本控制器应运而生,它方便我们查看历史版本、快速回退、创建版本分支等。总而言之,一个合格的程序员应学会熟练使用Git这个高效强大且开源的版本控制工具

基本概念

  • Git是分布式版本控制系统,即除了中心仓库以外,每个开发者在本地有完整的仓库副本

  • Git在本地的工作目录中,通过.git目录存储所有的数据

  • Git在本地分为三个区域

    • 工作区:当前、即时的内容,但修改没有保存在.git

    • 暂存区:通过git add保存的修改会被保存到暂存区,即.git

      暂存区又被称为index

    • 本地仓库:通过git commit将暂存区的修改提交到本地仓库

      当前指向的提交能通过HEAD指针访问

  • 文件的状态:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    ?? file.txt        # 未跟踪 (Untracked)
    A file.txt # 新增到暂存区 (Added to index)
    M file.txt # 已修改,未暂存 (M前有空格)
    M file.txt # 已修改,已暂存
    MM file.txt # 已修改,暂存后又修改了
    D file.txt # 已删除,已暂存
    D file.txt # 已删除,未暂存 (D前有空格)
    R old -> new # 重命名
    C file.txt # 复制
    UU file.txt # 冲突 (Unmerged)
  • Git会将一次提交生成一个哈希值来标记,并使用HEAD来指向当前所在的提交

  • Git将新增行和删除行都视为文件的修改,即视为M

    将修改行视为删除行和新增行的组合

  • Git还支持一个文件内的部分修改块进行单独暂存,这一部分修改默认使用Myers算法求得,结果被称为hunk

  • Git提供分支管理,分支名称指向该分支的最新提交,就像HEAD指向当前所在提交类似的

  • Git对远程仓库在本地的跟踪:支持使用自定义名称指向,例如originupstream

    注意这仍是在本地的,只不过是跟踪了远程仓库,它们不会自动更新

    可以通过origin/xxx访问本地存储的origin仓库的xxx分支

命令行符号

  • --:用于消除歧义,在--后跟文件名不会有歧义

  • ~x:相对引用,HEAD~x指向HEAD之前的沿着主分支的第x代父提交,x表示向上回溯的代数

    HEAD等价于HEAD~0HEAD~等价于HEAD~1~通常用于HEAD,但是也完全支持对分支名使用,支持链式调用HEAD~1~1

    1
    2
    3
              B---C---D [branch1] J [branch2]
    / \ / \
    [main] A---E---F---G---H---I---K

    例如对于K这类合并提交,K~1指向IK~2等价于I~1(非合并提交)因此指向HK~4等价于G~1指向F

  • ^x:相对引用,HEAD~x指向HEAD之前的第x父提交

    HEAD^等价于HEAD^1等价于HEAD~1HEAD^^等价于HEAD^2,支持混合的链式调用HEAD^~

    同样是上面的例子,K^1指向IK^2指向J

  • @{x}:引用日志的相对引用,表示某分支在x次操作之前的位置,即当前指向的是HEAD@{0}

    可以通过git reflog查看引用日志

  • :!<pattern>:用于排除<pattern>匹配的文件

  • A..B:两点语法,用于查看B相对于A不同的地方,可用于difflog等子命令

    哈希值、HEAD、分支名(包括和远程仓库关联的分支)都可以

    默认,AB省略时,使用HEAD

  • A...B:三点语法,用于查看AB相对于LCA(A, B)不同的地方,可用于difflog等子命令

基本配置(config)

  • git config子命令用于修改项目配置,也可以通过修改.git/config文件
  • git config --global [xxx]:修改全局配置,文件一般位于$HOME/.gitconfig
    • git config --global user.name "user name":提交时的用户名,带空格时需要

    • git config --global user.email xxx@xxx.com:提交时使用的邮箱

      由于github等平台以邮箱为索引用户唯一依据,所以user.email是必须设置好的,否则上传到远程仓库的话贡献就不是自己的了

    • git config --global core.editor nvim:使用git进入编辑器界面时,会使用系统默认的编辑器,也可以通过core.editor配置修改

    • git config --global core.pager bat:使用git loggit diff时,输出将默认交给系统分页器如less,可配置修改

    • git config --global url.<base>.insteadof <alias>:对url中的<base>起别名<alias>

      • git config --global url.git@github.com:.insteadof https://github.com/:例如将https替换为ssh前缀,可以做到每次都直接使用ssh访问github的效果

        还可以替换为自己搭建的github镜像

    • git config --global alias.<alias> <base>:对<base>(如checkout等)起别名<alias>

  • git config --list:查看所有配置
    • --show-origin:查看所有配置,同时显示来源
    • --local/--global:指定查看本地/全局
  • git config --get xxx:查看配置
  • git config --get-regexp xxx:模糊查询,使用简易的通配符匹配
  • git config --unset [xxx]:重置某个设置

基本使用

创建仓库

  • 创建本地仓库

    1
    2
    3
    git init    # 新建空仓库
    git init [dir] # 指定目录作为仓库
    git clone [href] # 克隆别人的仓库, href即资源位置

    新建后,该文件夹内就会出现.git隐藏文件夹,可以进行提交等操作

    hrefgithub中,你可以在一个项目的根目录下找到一个绿色的Code按钮,里面就有url

    href的一般格式:https://github.com/用户名/仓库名.gitgit@github.com:用户名/仓库名.git

暂存与提交(addcommit)

  • 暂存部分或全部文件的修改到暂存区

    1
    2
    3
    4
    5
    6
    git add file1 file2 ...
    git add *.java
    git add . # 暂存所有修改和新增
    git add . -- ":!target/" # 暂存除了`target/`以外的
    git add -A # 暂存所有修改、新增、删除
    git add -u [-- "xxx"] # 暂存所有或部分文件的修改、删除,即不暂存未跟踪文件
  • git add -i:打开交互式暂存

  • git add -p [xxx]:对全部文件或部分文件打开选择式暂存修改(hunk),会针对每一个hunk询问

    • y - stage this hunk
    • n - do not stage this hunk
    • q - quit; do not stage this hunk or any of the remaining ones
    • a - stage this hunk and all later hunks in the file
    • d - do not stage this hunk or any of the later hunks in the file
    • j - leave this hunk undecided, see next undecided hunk
    • J - leave this hunk undecided, see next hunk
    • g - select a hunk to go to
    • / - search for a hunk matching the given regex
    • e - manually edit the current hunk
    • ? - print help
  • git status:检查暂存区内的内容,这些内容和commit时的那些注释内容是一致的

    • -s:简短查看,使用MAD??等标志
  • 将暂存区内容提交到仓库:

    1
    2
    git commit -m "explanation" # -m后为注释, 一般只适用于只有标题的提交
    git commit # 进入编辑器编辑提交信息

    一般来说,每次更新或修复一个功能,就需要提交一次,因此,上述命令是很常用的

  • git commit --amend:追加提交,将暂存区提交到上一个提交,这种操作会产生新的哈希值

  • git commit --allow-empty:空提交,通常用于触发CI/CD

  • git commit -p:补丁式提交

  • git commit --no-edit:不进入编辑器直接提交

分支管理(branchswitch)

  • 分支在此前基本概念一节已经介绍过了,Git将本地分支存储在.git/refs/heads/下,远程分支存储在.git/remtoes/

  • git branch子命令用于管理分支

  • 查看分支:

    • -a:查看所有分支,包括远程分支,远程分支指的是本地中跟踪了远程仓库的分支,仍然存储在本地
    • -v:查看所有本地分支同时显示最新提交
    • -vv:在-v的基础上额外显示最新提交上是否有上游的远程分支
    • --merged:查看已经合并的分支
    • --no-merged:查看未合并的分支
    • -r:查看所有远程分支
  • 创建分支:

    • git branch <branchname>:基于当前分支创建新分支<branchname>
    • git branch <newb> <commit/branch>:基于指定提交/分支创建新分支
  • 迁移分支:

    • git branch -d <bname>:删除某分支,无法删除所在分支

    • git branch -D <bname>:强制删除某分支

    • git branch -m <newname>:重命名当前分支

    • git branch -m <oldname> <newname>:重命名指定分支

    • git branch -u <remotename>/<bname>:设置当前分支的上游分支

      等价于--set-upstream-to=<rname>/<bname>

      也可以在创建分支的同时使用--track参数直接设置上游分支

  • git 2.23之前,使用checkout进行切换分支、恢复文件,因此网上的教程多有使用checkout的,但此后checkout被分割成switchrestore

    git switch用于切换分支

    • -c <newb> [<base>]:切换时若分支不存在则创建
    • -f:默认若本分支存在未暂存/未提交的更改,则无法切换,-f表示丢弃本地修改(包括工作区和暂存区)
    • -m:携带所有未提交的修改带到下一个分支
    • -t:创建分支时指定跟踪远程分支
    • -:切换到上一个分支

恢复更改(restoreresetrmreflog)

  • git restore专注于工作区、暂存区和本地仓库HEAD指向提交之间的文件恢复,替代了checkoutreset的部分功能

  • git restore <file>:丢弃工作区的修改恢复到HEAD状态,暂存区仍保留

    PS:老版本中为git checkout -- <file>--消除歧义,但checkout这种方式不会提供操作日志,较为危险

    • --worktree:就是上述命令所默认使用的参数

    • --staged:将暂存区的文件移出到工作区

      PS:等价于git reset HEAD <file>

    • --patch:补丁式恢复

    • --source=<commit>:指定恢复的源,默认是HEAD

  • git reset <commit> [<file>]专注于不同提交之间的文件恢复,如果不跟文件则默认对所有文件执行,reset只能在本分支移动HEAD

    • --mixed:是默认模式,移动分支指针,将暂存区移动到工作区,不修改工作区
    • --soft:只移动分支指针
    • --hard:移动分支指针、清空工作区和暂存区
  • git rm <file>用于快捷地删除工作区内容和删除误暂存的内容

    • 默认情况下,删除工作区的文件同时停止跟踪

    • 使用--cached参数只停止跟踪,保留在工作区而不删除

    • 使用rm --cached配合commit --amend可以删除被误提交的二进制文件等(工作区不变):

      1
      2
      git rm --cached target/
      git commit --amend --no-edit

      但这种方式只适用于最新一次提交中误提交的情况,若历史提交中存在,则只能使用filter-repo

  • git reflog用于查看操作日志

查看差异(diff)

  • diff子命令用于精确地查看不同区之间的差异

  • diff输出的结构:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    diff --git a/source/_posts/git.md b/source/_posts/git.md
    index b4a4d11..301e28f 100644
    --- a/source/_posts/git.md
    +++ b/source/_posts/git.md
    @@ -117,6 +117,8 @@ tags:
    - e - manually edit the current hunk
    - ? - print help

    +- `git status`:检查暂存区内的内容,这些内容和`commit`时的那些注释内容是一致的
    + - `-s`:简短查看,使用`M`、`A`、`D`、`??`等标志
    - 将暂存区内容提交到仓库:

    ```sh

    第一行:a/xxx表示修改前的文件(即暂存区),b/xxx表示工作区文件

    第二行表示版本哈希值和文件权限

    第三、四行表示旧文件、新文件的路径

    第五行表示块头,@@ -旧起始行号,旧行数 +新起始行号,新行数 @@

    默认带有上下三行上下文,可以用git diff -Ux来显示x

  • git diff:查看工作区相对于暂存区的区别

  • git diff --staged:查看暂存区相对于本地仓库之间的区别

    旧版本中为git diff --cached

  • git diff HEAD

  • git diff commitA commitB,等价于..

  • git diff xxx...xxx:三点运算符比较

  • 一些参数:

    • --stat:显示每个文件的变更统计
    • --shortstat:显示每个文件的简短变更统计
    • --name-only:仅显示文件名称
    • --name-status:显示文件状态符号和文件名称
    • --numstat:显示变更行数统计

合并提交(mergerebasecherry-pick)

  • git merge用于将两个分支合并,创建一个新的合并提交

    • git merge xxx:使xxx分支合并到当前分支

    • 快进合并:合并者是被合并者的直接祖先,则合并者直接移动到被合并者,不产生合并提交

      1
      2
      3
      4
      5
      6
      7
      8
      [main] A---B---C
      \
      [branch1] D---E---F

      (main) git merge branch1

      [main] A---B---C---D---E---F
      [branch1] |
    • 三方合并:合并者本身有一定修改,则创建一个合并提交

      1
      2
      3
      4
      5
      6
      7
      8
      9
      [main] A---B---C
      \
      [branch1] D---E---F

      (main) git merge branch1

      [main] A---B---C-------G
      \ /
      [branch1] D---E---F
    • 默认情况下,会保留被合并者的所有提交记录

    • --squash:将被合并者的所有提交合并成一个提交,此时需要手动commit

  • git rebase用于变基,即重新设置基准点而不是创建合并提交

    发起者会提取基准分支的额外提交(如下图的C)并以基准分支的最新提交作为新的基准点,然后在这个新的基准点的基础上应用本分支的历史提交(DEF),它们的提交哈希值会更改

    1
    2
    3
    4
    5
    6
    7
    8
    9
    [main] A---B---C
    \
    [branch1] D---E---F

    (branch1) git rebase main

    [main] A---B---C
    \
    [branch1] D'---E'---F'

    注意rebase通常是在非主分支上执行的,和mergecherry-pick不同,rebase通常用于整理这个分支的提交,使其看起来是线性的

    如果一个分支已经push上去了,就不要用rebase了,因为这会导致大量的更改,使得其它在这个分支上工作的协作者全都要额外处理,而且rebase产生冲突的概率是最大的

  • git cherry-pick xxx ...用于复制提交,也不是创建合并提交,而是在源分支中自定义选择一些提交,复制到当前分支上,创建若干等量的新提交

    1
    2
    3
    4
    5
    6
    7
    8
    9
    [main] A---B---C
    \
    [branch1] D---E---F

    (main) git cherry-pick branch1~..branch1~2

    [main] A---B---C---D'---E'
    \
    [branch1] D---E---F
  • 在使用上面三条命令时都有可能遇到合并冲突,冲突发现后使用任何命令都会失败并提醒

    • git merge/rebase/cherry-pick --abort:中止合并

    • git merge/rebase/cherry-pick --continue:解决冲突后继续合并

    • 冲突内容:会是下面的格式

      1
      2
      3
      4
      5
      <<<<<<< HEAD
      ours
      =======
      theirs
      >>>>>>> branchname

      可以手动编辑文件直到没有冲突为止,也可以通过checkout --ourscheckout --theirs完全采纳我们的和它们的

      这里的ours始终是对于当前分支所说的,theirs始终是对于被合并分支来说的

      解决冲突永远修改的都是当前分支中发生冲突的提交,不会修改被合并分支

查看提交(logshow)

  • git log用于查看提交历史
    • --oneline:单行查看,只显示title
    • --graph:显示分支结构
    • -x:显示最近x个提交
    • -p:显示每次提交的差异
    • --stat:显示统计
  • git show [commit/branch/tag]用于查看提交详情

存储更改(stashworktree)

  • git stash用于暂时存储工作区+暂存区,从而能够切换分支而既不提交也不丢弃

    • git stash等价于git stash push,将当前index包括工作区更改存储起来

      • -u:这里不是update而是untracked,存储未跟踪文件
      • -a:包括所有文件,包括被.gitignore忽略的
    • git stash list:查看存储列表,每个存储项的格式:

      stash@{x}: WIP on branch_name: commit_hash commit_title

      WIP就是半成品的意思

    • git stash pop [stash@{x}]:恢复最近的存储,可以指定存储记录,同时将存储记录删除

    • git stash apply [stash@{x}]:同pop但不会删除存储记录

    • git stash drop [stash@{x}]:删除指定存储记录

    • git stash clear:删除所有记录

    • git stash show [stash@{x}] [-p]:查看存储内容,-p表示是否详细diff

  • git worktree用于创建一个子目录,是另一种解决无法灵活切换分支的办法

    它将创建一个指定的新的目录,其中的.git/符号链接指向当前目录的.git/,则在新目录下的提交可以直接关联到原目录

    • git worktree add <dir> <branch_name>:新增,此后可以用cd来等效切换分支了
    • lockunlock:加锁与解锁,用于防止意外删除
    • git worktree remove <dir>:删除
    • git worktree list:查看工作树

其它命令

  • git tag <tagname>:标记本提交为一个自定义tag
  • git clean:清理未跟踪的文件
  • git bisect:用于二分查找引入bug的第一个提交
    • git bisect start:开始二分查找
    • git bisect bad:标记当前为坏提交
    • git bisect good <tag>:标记一个已知的好提交,即没有bug的,随后将自动跳转到中间的某个提交
    • git bisect bad/good:标记当前提交为坏/好,然后自动跳转到中间
    • git bisect reset:提前结束查找

远程仓库相关

管理远程仓库(remote)

  • git remote子命令用于管理远程仓库

  • 空本地仓库关联远程仓库

    1
    git remote add [alias] [url] # 关联到某个远程仓库, alias是这个仓库的别名
  • git remote rename/remove/set-url/show

  • 常见的工作流为:先fork源仓库,然后关联自己的这个远程仓库,此后fetch上游仓库后合并到本地仓库中

    1
    2
    3
    4
    5
    6
    7
    git remote add origin [https://github.com/me/xxx.git]    # fork 下来的远程仓库通常命名为 origin
    git remote add upstream [https://github.com/other/xxx.git] # 源仓库通常命名为 upstream (上游)
    git pull origin # 直接 pull 下来
    # 本地完成功能后, 先合并上游的新更新再push到origin
    git fetch upstream
    git merge upstream/main
    git push origin
  • 已有本地仓库关联远程仓库

    1
    2
    git remote add origin [https://github.com/me/xxx.git]
    git push origin # 直接 push 即可

下载(fetchpullpush)

  • git fetch remote_name [branch_name]用于下载指定的远程仓库并存储到本地的远程分支中,默认下载全部分支
  • git fetch --prune:清理已删除的远程分支引用
  • git pull用于一键式地fetch+merge,可以使用--rebase表示fetch+rebase
  • git push remote_name localbr:remotebr用于将本地提交推送
    • git push rname abc等价于git push rname abc:abc,即默认推送到同名分支

      这里是真正的推送到了远程仓库的分支里,而不是本地仓库的远程分支

    • -u:推送并设置上游跟踪

      设置上游分支后,此后只需要git push即可

    • --delete xxx:删除远程仓库的对应分支

      或者git push origin :xxx

    • git push origin <tag>:推送某个tag,使用--tags推送所有tag

安全事项

  • 不要对已经push的提交进行修改,比如:
    • push了一个提交后amend
    • push了一个分支后尝试rebase,导致修改了这个分支上的所有提交
  • merge后的分支应该删除,而不是继续在上面提交
  • 从安全的角度上,pull缺少了审查步骤,应尽量使用分步骤地fetch+merge而不是pull

.gitignore

  • .gitignore中匹配的文件会被git忽略,它们甚至不会成为未跟踪文件
  • #开头是注释
  • *匹配任意字符(非目录分隔符)
  • ?匹配单个字符
  • **匹配任意字符
  • []匹配字符类
  • /结尾表示忽略目录

.gitattribute

  • .gitattribute是一系列预定义的处理规则,可以对不同文件进行不同的处理,包括设置处理工具、合并策略、换行符等
  • 语法在这里不多赘述

轶闻

diff的默认算法Myers

  • myers
  • 如图,myers通过层次遍历的方法,寻找(0, 0)(n, m)的最短路径

  • 横轴的每个点代表原文件的每一行,纵轴的每个点代表着新文件的每一行,因此n是原文件的总行数,m是新文件的总行数

  • 允许的操作是向右、向下、向右下移动

    向右移动(x->x+1)意味着删除原文件的第x+1行,向下移动(y->y+1)意味着新增新文件的第y+1行,它们的消耗都为一

    向右下移动((x,y)->(x+1,y+1))意味着原文件的第x+1行和新文件的第y+1行匹配成功,花费为零但有条件(两文件的对应行一致)

  • diff的目标就是找到人类习惯上的好的差异:差异尽可能紧凑、尽可能少,不变的行尽可能多

    而自然,最短路径将是右下操作最多的,刚好和好的差异相匹配

    寻找最短路径的过程就是宽度优先遍历并找到最先到达(n, m)的路径然后回溯,自然根据规则就能得到新增、删除的对比补丁了

    那么一个hunk自然就是连续的修改块了

git集成插件

  • lazygit是一个命令行工具,提供更好的UI界面和快捷键
  • neogit是一个在nvim中的另一个集成插件,也是提供了更好的UI和快捷键
  • vimdiff是一个能够更直观地查看diff以及修改冲突的工具