跳转至

Git

Note

本頁面將着重介紹 Git 這一版本控制系統,與 GitHub 相關的內容,請參考 GitHub 幫助如何參與 - OI Wiki

Git 是目前使用最廣泛的版本控制系統之一。OI Wiki 也使用了 Git 作為版本控制系統。

安裝

參見 Git - Downloads

配置

Git 根據配置文件的應用範圍,將配置文件分為不同的等級,其中較常用的有兩個級別1

  1. 適用於當前用户的全局配置文件,該用户操作本系統上的所有倉庫時都會查詢該配置文件。
  2. 適用於當前倉庫的配置文件。

當多個配置文件對同一個選項作出設置的時候,局部設置會自動覆蓋全局設置。因此如果需要在某個倉庫應用特定的設置的話,只需更改該倉庫下的特定設置即可,不會對全局設置造成影響。

修改配置文件需要用到 git config 命令。

設置用户信息

安裝 Git 後,第一件事情就是設置你的用户名和郵箱。這些信息在每次提交時都會用到。

1
2
$ git config --global user.name "OI Wiki"
$ git config --global user.email oi-wiki@example.com
Note

這裏給出的用户名和郵箱僅供演示。您在根據本頁面的內容配置時,請記得將這裏的用户名和郵箱改成自己的信息。

這裏的 --global 表示修改的是全局配置,即該設置對當前用户下的所有倉庫均有效。如果不添加 --global 選項,則會默認修改當前倉庫下的配置文件。

如果想要修改某個倉庫的特定設置,只需在該倉庫下執行不帶 --global 的命令即可。

配置編輯器

1
$ git config --global core.editor emacs

執行如上命令可以將編輯器更改為 Emacs

在 Windows 下,Git 的默認編輯器可以在安裝 Git 時選擇(見前文)。之後若要修改,在 Git Bash 裏輸入如上命令,將編輯器名換成編輯器的絕對路徑,運行命令即可。

顯示配置

可以通過 git config -l 列出當前已經設置的所有配置參數。使用 git config --global -l 可以列出所有全局配置。

倉庫操作基礎

新建 Git 倉庫

新建一個 Git 倉庫非常簡單,只需在想要建立倉庫的文件夾輸入如下命令:

1
$ git init

Git 將在當前文件夾新建一個 .git 文件夾,一個倉庫就這樣建好了。

如果想把一個倉庫克隆到自己的電腦上(比如將 OI Wiki 的代碼拷貝到本地上進行編輯),採用 git clone 命令即可。

1
$ git clone https://github.com/OI-wiki/OI-wiki
遠程倉庫的鏈接

這裏給出的倉庫鏈接是 HTTP(S) 鏈接,也即我們採用了 HTTP(S) 方式連接到遠程倉庫。

事實上,連接到遠程倉庫的方式還有多種。其中使用 ssh 連接到遠程倉庫的方法更為方便和安全,在「遠程倉庫的管理」部分我們會簡單介紹使用 ssh 連接到遠程倉庫的方法。

這樣,被克隆的倉庫的內容就會被儲存到當前文件夾下一個與倉庫同名的新文件夾。在本例中,當前文件夾下會出現一個名為 OI-wiki 的新文件夾。

跟蹤文件

在對倉庫的文件做出了一些更改後,這些更改需要被納入到版本管理當中去。

使用 git status 命令可以查看當前倉庫文件的狀態。

舉個例子,在一個空倉庫中新增了一個 README.md 文件後,執行 git status 命令的效果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ git status
On branch master

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)

        README.md

nothing added to commit but untracked files present (use "git add" to track)

這裏的 Untracked files 指的是 Git 之前沒有納入版本跟蹤的文件。如果文件沒有納入版本跟蹤,對該文件的更改不會被 Git 記錄。

執行 git add <文件> 命令可以將指定的文件納入到版本跟蹤中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ git add README.md # 將這個文件納入到版本跟蹤中
$ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   README.md

這時 README.md 已經納入了版本跟蹤,放入了暫存區。接下來只需執行 git commit 命令就可以提交這次更改了。

但在進行這一工作之前,讓我們先對 README.md 做點小更改。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ vim README.md # 隨便更改點東西
$ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   README.md

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:   README.md

你會發現 README.md 同時處於暫存區和非暫存區。實際上,是否處於暫存區是對於更改而言的,而不是對於文件而言的,所以對 README.md 的前一次更改已被納入暫存區,而後一次更改還沒有。如果這時候執行 git commit 命令,只有處於暫存區的更改會被提交,而非暫存區的更改,則不會被提交。

Git 給了一條提示,執行 git add README.md 就可以將非暫存區的更改放入暫存區了。

一次性將所有更改放入暫存區

git add 命令會將對指定的文件的更改放入暫存區中。

在多數情況下,用户更期望一次性將所有更改都放入暫存區中,這時候可以應用 git add -A 命令。該命令會將所有更改(包括未被納入版本跟蹤的文件,不包括被忽略的文件)放入暫存區。

如果只需更新已被納入版本跟蹤的文件,而不將未納入版本跟蹤的文件加入暫存區,可以使用 git add -u

忽略文件

有些時候我們並不希望將一些文件(如可執行文件等)納入到版本跟蹤中。這時候可以在倉庫根目錄下創建 .gitignore 文件,在該文件裏寫下想要忽略的文件。Git 將不會將這些文件納入到版本跟蹤中。

例如,*.exe 將自動忽略倉庫裏的所有擴展名為 .exe 的文件。

現在將非暫存區的文件加入暫存區,將所有更改一併提交(commit)。

1
2
3
4
5
$ git add README.md
$ git commit # 接下來會彈出編輯器頁面,你需要寫下 commit 信息
[master (root-commit) f992763] initial commit
 1 file changed, 2 insertions(+)
 create mode 100644 README.md

現在重點觀察一下這一次 commit 的信息。

master 表示當前位於 master 分支(關於分支的問題,下文將會詳細介紹),b13c84e 表示本次提交的 SHA-1 校驗和的前幾位,後面則是本次提交的信息。

需要特別關注的是這裏的 SHA-1 校驗碼,每個校驗碼都與某個時刻倉庫的一個快照相對應。利用這一特性我們可以訪問歷史某個時刻的倉庫快照,並在該快照上進行更改。

接下來兩行則詳細説明了本次更新涉及的文件更改。

另外,commit 過程中可以利用幾個參數來簡化提交過程:

  • -a:在提交前將所有已跟蹤的文件的更改放入暫存區。需要注意的是未被跟蹤的文件(新創建的文件)不會被自動加入暫存區,需要用 git add 命令手動添加。
  • -m:該參數後跟提交信息,表示以該提交信息提交本次更改。例如 git commit -m "fix: typo" 會創建一條標題為 fix: typo 的 commit。

查看提交記錄

使用 git log 命令可以查看倉庫的提交歷史記錄。

可以看到,提交歷史裏記錄了每次提交時的 SHA-1 校驗和,提交的作者,提交時間和 commit 信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ git log
commit ae9dd3768a405b348bc6170c7acb8b6cb5fe333e (HEAD -> master)
Author: OI Wiki <oi-wiki@example.com>
Date:   Sun Sep 13 00:30:18 2020 +0800

    feat: update README.md

commit f99276362a3c260d439364c505a7a06859f34bf9
Author: OI Wiki <oi-wiki@example.com>
Date:   Sun Sep 13 00:06:07 2020 +0800

    initial commit

分支管理

為什麼版本管理中需要分支管理呢?答案主要有兩點:

  1. 直接更改主分支不僅會使歷史記錄混亂,也可能會造成一些危險的後果。
  2. 通過分支,我們可以專注於當前的工作。如果我們需要完成兩個不同的工作,只需開兩個分支即可,兩個分支間的工作互不干擾。

在 Git 中,簡單來説,分支就是指向某個快照的指針。每次提交時,Git 都會為這次提交創建一個快照,並將當前分支的指針移動到該快照。

另外還有一個 HEAD 指針,它指向當前所在的分支。

切換分支的過程,簡單來説就是將 HEAD 指針,從指向當前所在的分支,改為指向另外一個分支。在這一過程中,Git 會自動完成文件的更新,使得切換分支後倉庫的狀態與目標分支指向的快照一致。

分支的創建

利用 git branch 命令可以創建分支,git switch 命令可以切換分支,git switch -c 命令可以創建分支並切換到這個新分支。

1
2
3
4
5
$ git switch -c dev # 創建一個叫做 dev 的新分支並切換當前分支到 dev
Switched to branch 'dev'
$ git branch # 查看分支列表
  master
* dev

dev 前面的星號代表該倉庫的當前分支為 dev,接下來對這個倉庫的更改都將記錄在這個分支上。

試着創建一個新文件 aplusb.cpp

1
2
3
4
5
6
$ vim aplusb.cpp
$ git add aplusb.cpp
$ git commit -m "feat: add A+B Problem code"
[dev 5da093b] feat: add A+B Problem code
 1 file changed, 7 insertions(+)
 create mode 100644 aplusb.cpp

現在切換回 master 分支,這時候文件夾中沒有了 aplusb.cpp,一切都回到了剛剛創建 dev 分支時的狀態。這時候可以在 master 分支上繼續完成其他的工作。

1
2
3
4
5
6
$ git switch master
Switched to branch 'master'
$ vim README.md # 對 README 做些小改動
$ git commit -a -m "feat: update README.md"
[master 5ca15f0] feat: update README.md
 1 file changed, 1 insertion(+), 1 deletion(-)

下面用一張圖來解釋剛才的操作過程。

master 分支被標紅,表明在這幾次操作後,它是當前分支(即 HEAD 指向的位置)。

  • 最開始時 master 指向 ae9dd37 這一快照。
  • 接下來在 master 所在的位置創建了一個新的 dev 分支,該分支一開始和 master 指向相同位置。
  • dev 分支上作了一些更改(創建了 aplusb.cpp),進行了一次提交,本次提交後,dev 分支指向 5da093b 這一快照。
  • 切換回 master 分支後,因為 master 分支還指向 ae9dd37,還沒有創建 aplusb.cpp,因此倉庫中沒有這一文件。
  • 接下來在 master 分支上進行更改(更新了 README.md),進行了一次提交,master 分支指向了 5ca15f0 這一快照。

分支的合併

當一個分支上的工作已經完成,就可以將這些工作合併到另外一個分支上去。

還是接着上面這個例子,dev 分支的工作已經完成,通過 git merge 命令可以將該分支合併到當前分支(master)上:

1
2
3
4
5
$ git merge dev
Merge made by the 'recursive' strategy.
 aplusb.cpp | 7 +++++++
 1 file changed, 7 insertions(+)
 create mode 100644 aplusb.cpp

這次合併具體是怎麼執行的呢?

在合併之前,master 指向 5ca15f0,而 dev 指向 5da093b,這兩個狀態並不在一條鏈上。

Git 會找到這兩個狀態的最近公共祖先(在上圖中是 ae9dd37),並對這三個快照進行一次合併。三個快照合併的結果作為一個新的快照,並將當前分支指向這一快照。

合併過程本身也是一次提交,不過與常規提交不同的是,合併提交有不止一個前驅提交,它是多個提交狀態合併後的結果。

在合併完成後,dev 分支就完成了它的使命,這時候可以利用下面的命令刪除 dev 分支:

1
$ git branch -d dev # 對於未合併的分支,可以使用 -D 參數強制刪除

不過合併過程並非總是這麼順利,在某些情況下,合併過程可能會出現衝突,這個問題接下來會講到。

解決合併衝突

如果在兩個分支中,對同一個文件的同一部分進行了不同的更改,Git 就無法自動合併這兩個分支,也就是發生了合併衝突。

接着上面的例子,假如你在合併後的 master 分支的基礎上,新開了一個 readme-refactor 分支,準備重寫一份自述文件。但因為一些疏忽,你同時更改了 readme-refactormaster 分支的自述文件。

剛開始自述文件是這樣的:

1
2
3
# This is a test repo.

This repo includes some c++ codes.

readme-refactor 分支下的自述文件是這樣的:

1
2
3
# Code Library

This repo includes some c++ codes.

master 分支下的自述文件是這樣的:

1
2
3
# This is a code library.

This repo includes some c++ codes.

這時候運行 git merge readme-refactor 命令,Git 提示出現了合併衝突。

執行一下 git status 命令,可以查看是哪些文件引發了衝突。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ git status
On branch master
You have unmerged paths.
  (fix conflicts and run "git commit")

Unmerged paths:
  (use "git add <file>..." to mark resolution)

    both modified:      README.md

no changes added to commit (use "git add" and/or "git commit -a")

如何解決衝突?對於每個發生了合併衝突的文件,Git 都會在這些文件中加入標準的衝突解決標記。比如這個例子中的 README.md 文件,打開後它長這個樣子:

1
2
3
4
5
6
7
<<<<<< HEAD
# This is a code library.
======
# Code Library
>>>>>> readme-refactor

This repo includes some c++ codes.

====== 作為分界線將兩個分支的內容隔開,<<<<<< HEAD 標記和 ====== 之間的部分是 HEAD 指針(master 分支)的內容,而 ======>>>>>> readme-refactor 標記之間的部分是 readme-refactor 分支的內容。

通過編輯文本來處理衝突,刪除這些衝突標記,保存文件,將這些文件納入暫存區後提交,就可以解決合併衝突了。

1
2
3
$ git add README.md # 將發生衝突的文件納入暫存區
$ git commit
[master fe92c6b] Merge branch readme-refactor into master

其他合併方式

默認情況下,Git 採用 Merge(合併)的方式合併兩個分支。使用該方法將分支 B 併入分支 A 時,會將 B 分支的所有 commit 併入 A 分支的提交歷史中。

除此以外,Git 還提供了兩種合併分支的方式:Squash(壓縮)和 Rebase(變基)。

Squash(壓縮)

使用 Squash 方式將分支 B 併入分支 A 時,在 B 分支上的所有更改會被合併為一次 commit 提交到 A 分支。

git merge 中加入 --squash 參數即可使用 Squash 方式進行分支合併。

1
git merge <branch> --squash

需要注意的是,在執行上述命令後,Git 只會將 B 分支的所有更改存入 A 分支的緩衝區內,接下來還需要執行一次 git commit 命令完成合並工作。

使用 Squash 方式合併可以簡化 commit 記錄,但是會丟失具體到每一次 commit 的信息(每次 commit 的提交者,每次 commit 的更改等等),只留下合併為一個整體的信息(每次 commit 的提交者會以 "Co-authored-by" 的形式在提交信息中列出)。但如果是在 GitHub 上進行 Squash and Merge,原有的信息都可以在 Pull Request 中查看。

Rebase(變基)

使用 Rebase 方式將分支 B 併入分支 A 時,在 B 分支上的每一次 commit 都會單獨添加到 A 分支,而不再像 Merge 方式那樣創建一個合併 commit 來合併兩個分支的內容2

首先,切換到 B 分支,接下來將 B 分支變基到 A 分支:

1
2
git checkout B
git rebase A

現在切回到 A 分支,再執行一次 git merge 命令,即可完成將 B 分支的內容合併到 A 分支的工作。

1
2
git checkout A
git merge B

使用 Rebase 完成合並可以讓提交歷史線性化,在適當的場景下正確地使用 Rebase 可以達到比 Merge 更好的效果。但是這樣做會改變提交歷史,在進行 Rebase 時和 Rebase 後再進行相關合並操作時都會增加出現衝突的可能,如果操作不當可能反而會使提交歷史變得雜亂。因此,如果對 Rebase 操作沒有充分的瞭解,不建議使用。

管理遠程倉庫

在本地完成更改後,你可能會需要將這些更改推送到 GitHub 等 Git 倉庫託管平台上。託管在這些平台上的倉庫就歸屬於遠程倉庫的範疇——你可以從這些倉庫中獲取信息,也可以將你作出的更改推送到遠程倉庫上。與其他人的協作往往離不開遠程倉庫,因此學會管理遠程倉庫很有必要。

遠程倉庫的查看

使用 git remote 命令可以查看當前倉庫的遠程倉庫列表。

如果當前倉庫是克隆來的,那麼應該會有一個叫做 origin 的遠程倉庫,它的鏈接就是克隆時用的鏈接。

1
2
$ git remote
origin

如果要查看某個遠程倉庫的詳細信息的話,可以這樣操作:

1
2
3
4
5
6
7
8
9
$ git remote show origin
* remote origin
  Fetch URL: git@github.com:OI-wiki/OI-wiki.git
  Push  URL: git@github.com:OI-wiki/OI-wiki.git
  HEAD branch: master
  Remote branches:
    git             tracked
    master          tracked
  ...

遠程倉庫的配置

執行 git remote add <name> <url> 命令可以添加一個名字為 name,鏈接為 url 的遠程倉庫。

執行 git remote rename <oldname> <newname> 可以將名字為 oldname 的遠程倉庫改名為 newname

執行 git remote rm <name> 可以刪除名字為 name 的遠程倉庫。

執行 git remote get-url <name> 可以查看名字為 name 的遠程倉庫的鏈接。

執行 git remote set-url <name> <newurl> 可以將名字為 name 的遠程倉庫的鏈接更改為 newurl

從遠程倉庫獲取更改

在遠程倉庫中,其他人可能會推送一些更改,執行 git fetch 命令可以將這些更改獲取到本地。

1
$ git fetch <remote-name> # 獲取 <remote-name> 的更改

需要注意的是,git fetch 命令只會獲取遠程倉庫的更改,而不會將這些更改合併到本地倉庫中。如果需要將這些更改進行合併,可以使用 git pull 命令。在默認情況下,git pull 相當於 git fetchgit merge FETCH_HEAD

1
$ git pull <remote-name> <branch> # 獲取 <remote-name> 的更改,然後將這些更改合併到 HEAD

將更改推送到遠程倉庫

當你完成了一些更改之後,使用 git push 命令可以將這些更改推送到遠程倉庫。

1
$ git push <remote> <from>:<to> # 將本地 <from> 分支的更改推送至 <remote> 的 <to> 分支

根據遠程倉庫的要求,可能會要求你輸入遠程倉庫賬户的用户名和密碼。

需要注意的是,你的更改能成功推送,需要滿足兩個條件:你擁有向這個倉庫(分支)的寫入權限,且你的這個分支比遠程倉庫的相應分支新(可以理解為沒有人在你進行更改的這段時間進行了推送)。當遠程分支有當前分支沒有的新更改時,可以執行 git pull 命令完成合並再提交。

如果你需要強制將本地分支的更改推送到遠程倉庫的話,可以加入 -f 參數。此時 遠程倉庫的提交歷史會被本地的提交歷史覆蓋,因此該命令應謹慎使用。更好的選擇是使用 --force-with-lease 參數,該參數僅在遠程倉庫沒有更新時才會進行覆蓋。需要注意的是,此處「更新」是相對於上一次 fetch 而言的,如果使用了 VS Code 提供的 Auto Fetch 功能,可能會沒有注意到更新而使 --force-with-lease-f 一樣危險。

追蹤遠程分支

通過將一個本地分支設定為追蹤遠程分支,可以方便地查看本地分支與遠程分支的差別,並能簡化與遠程分支交互時的操作。

在開始追蹤前,你需要先執行 git fetch <remote-name> 將遠程倉庫的信息抓取到本地。

接下來執行 git switch <remote-branch>,會在本地自動創建名字為 <remote-branch> 的新分支,並設定該分支自動追蹤相應的遠程分支。

Note

需要注意,只有當本地不存在該分支,且恰好只有一個遠程分支的名字與該分支匹配時,Git 才會自動創建該分支且設定其追蹤相應的遠程分支。

這時候執行 git status 命令,會提示當前分支與遠程分支之間的差別。

因為設定了本地分支追蹤的遠程分支,向遠程分支推送的命令也被簡化了。只需要執行 git push 命令,在本地分支上作出的更改就能被推送至其追蹤的遠程分支。

對於本地已有的分支,設定其對應的遠程追蹤分支也很容易。只需在當前分支下執行 git branch -u <remote-name>/<remote-branch>,就可以設定當前的本地分支追蹤 <remote-name>/<remote-branch> 這一遠程分支。

使用 ssh 連接

與 HTTP(S) 相比,使用 ssh 連接到遠程倉庫更為方便安全。

在使用 ssh 連接到遠程倉庫之前,需要先在本地添加 ssh 密鑰。接下來需要將本地添加的 ssh 密鑰的 公鑰 上傳到遠程倉庫賬户。

考慮到本文主要是給 OI Wiki 的貢獻者提供一個使用 Git 的教程,這裏直接給出 GitHub Docs 提供的教程,供各位讀者參考。

完成以上步驟後,你就可以通過 ssh 連接到遠程倉庫了。下面就是一條通過 ssh 連接 clone OI Wiki 倉庫的命令:

1
$ git clone git@github.com:OI-wiki/OI-wiki.git

將更改推送至遠程倉庫的過程與使用 HTTP(S) 連接類似。但使用 ssh 連接可以免去驗證遠程倉庫帳號密碼的過程。

Git GUI Tools

對於不熟悉命令行的同學,純命令行的 Git 的上手難度可能會偏高,而藉助 GUI 工具可以一定程度上降低 Git 的上手難度。此外,相比於命令行,GUI 工具在查看 diff 以及 log 時在體驗上有一定程度的提高。

Git 本身自帶有 GUI,市面上也有很多優秀的 Git GUI 工具,例如針對 Windows 用户的 TortoiseGit3,支持 Windows 和 Mac 的 Sourcetree4等。

這裏簡單介紹一下 TortoiseGit 的使用。下載並安裝好 TortoiseGit 之後,在本地倉庫的目錄下,單擊鼠標右鍵,在右鍵菜單中就可以看到 Git 的各個功能。

TortoiseGit Example

詳細的使用方法這裏不再贅述,可以參考官網裏的使用文檔或者通過搜索引擎學習,例如 TortoiseGit Manual

很多 GUI 工具都有官方中文支持,例如 Git Desktop 以及 TortoiseGit。但是還是會有部分翻譯看起來較為變扭,推薦使用英文版本。

外部鏈接

參考資料與註釋


  1. 事實上 Git 還有一個針對系統上每一個用户及系統上所有倉庫的通用配置文件,該配置文件覆蓋範圍最廣,等級在用户配置文件之上。因為該配置實踐中較少使用,這裏不再展開。 

  2. Pro Git Book 中提供了可視化的 Rebase 過程圖,藉助圖片讀者可以更好地理解 Rebase 的機制。 

  3. TortoiseGit 

  4. Sourcetree