Git

資訊系必備的版本管理工具

  • 基本介紹
    • git 原理
    • 基本操作
  • 實戰體驗
    • 進階操作

大綱

Git 前言

關於這是什麼

為什麼需要版本控制?

  • 不小心改到檔案然後按下儲存
  • 有雷隊友把 code 整個刪掉了
  • 大專案出 bug 了,想找出是什麼時候開始出了問題

為什麼需要版本控制?

Git 可以做什麼?

Cloud

Local

Cloud

Clone

Local

Repository

Cloud

Pull

Push

Local

Cloud

Local 1

Local 2

Merge

關於「Git」這個名字

  • 可以發音唸出的隨機三個字母組合,而且並未被實際用在任何 UNIX 指令上
  • Global Information Tracker,如果想硬湊的話
  • 愚蠢的、鄙視和卑鄙的,來自字典的解釋

關於「Git」和「Github」

  • Github 是一個「線上軟體原始碼代管服務平台」,它會用 Git 來進行版本控制
  • 還有其他和 Github 類似的平台,例如 gitlab, bitbucket 等等

基本概念

區域劃分

檔案狀態

基本流程

  • 修改 / 新增檔案
  • 移到 staging area
  • 存到 repository

誰說資訊系不用GUI?

終端機

終端機

Sublime Merge

  • 簡潔易懂
  • 指令操作
  • 功能齊全
  • 顯示分支樹
  • 免費

下載安裝

官網:https://www.sublimemerge.com

介面

操作

指令

Ctrl + p

Git 基礎

關於自己做事

基礎指令

初始設定

創建倉庫 / 環境設定

創建倉庫

  • repository(repo)
  • 資料夾 為單位
  • 讓 git 知道這些內容要被管理
  • 初始化 git

儲存位置

Sublime Merge

Sublime Merge

選單 > File > New Repository

環境設定

  • 記錄作者資訊
  • 調整記錄資訊
    • file mode
  • 調整環境設置
    • 編輯器

e.g. 作者資訊

儲存位置

  • Global
    • ~/.gitconfig
    • 影響所有 repo

  • Local
    • <repo>/.git/config
    • 目前所在 repo

儲存位置 - Global

儲存位置 - Local

Sublime Merge

Sublime Merge

創建 commit

放上卡車 / 檢查卡車 / 送進倉庫

放上卡車

git add

用途?

  • 掌控每個 commit 大小
  • 避免把某些資訊丟進倉庫
  • 為工作設定斷點

Sublime Merge

檢查卡車

內容資訊

Sublime Merge

modified

untracked

staged

紀錄「改變」

modified

staged

送進倉庫

git commit

Sublime Merge

commit message

成功!

Demo & Lab

  1. 新增一個 repo 
  2. 做好環境設定
  3. 新增檔案 a.txt 並放入卡車
  4. 把卡車送進倉庫

指令 ver.

git init / config / add / status / commit

Review

  • 新增倉庫 git init
  • 環境設定 git config
  • 放上卡車 git add
  • 檢查卡車 git status
  • 送進倉庫 git commit

創建倉庫

  • $ git init
  • $ git init <repo_name>

環境設定

  • $ git config [--global] user.name "<your_name>"
  • $ git config [--global] user.email "<your_email>"

放上卡車

  • $ git add a.txt
  • $ git add *.c
  • $ git add .
  • $ git add --all

檢查卡車

$ git status

modified

untracked

staged

送進倉庫

  • $ git commit
  • $ git commit -m "<message>"

組合技

  1. $ git status
  2. $ git commit

請養成 commit 前檢查 status 的好習慣

Demo

$ 指令 time

復原操作

git restore / rm

unstaged

  • 剛剛 git add 某些檔案
  • 發現這個東西還不能丟到倉庫
  • 從 staging area 移除

Sublime Merge

unmodified

  • 剛剛修改了一些檔案
  • 測試時發現整陀爛光光
  • 只能回復到上一個 commit 的狀態

Sublime Merge

Sublime Merge

Demo & Lab

Task: 創建並讓 b.txt 在 staged 跟 modified 間反覆橫跳移動

指令 ver.

git restore / rm

unstage

  • $ git restore --staged a.txt
  • $ git restore --staged *.c

第一個 staged

  • 新的 repo
  • 新增 a.txt 並放入 staging area
  • 從 staging area 移除

指令

哭阿

git restore

  • git restore 是以 commit 為基底進行復原
  • 沒有半個 commit 當然會出事情

怎麼辦

  • 下載 Sublime Merge
  • 重開一個 repo 
  • 不管了直接 commit
  • ...?

指令

指令

  • $ git rm --cached a.txt
  • $ git rm --cached *.c

git rm

  • 把檔案從 tracked 直接刪除
  • --cached:改成變為 untracked

unmodified

  • $ git restore a.txt

複習

  • $ git restore [--staged] <file>
  • $ git rm [--cached] <file>

太多好難記?

Demo

$ 指令 time

其他指令

操作歷史

查看歷史 / 刪除歷史 / 暫存桌面 / 回到過去

查看歷史

  • commit 順序
  • 作者與內容
  • 搜尋功能
    • 作者
    • 時間
    • 檔案

Sublime Merge

Sublime Merge

進階搜尋

刪除歷史

  • 不小心手殘把錯的東西 git add
  • 不小心手殘把錯的東西 git commit
  • 完蛋zzz

刪除歷史

  • 創造一個新的 commit 修改
  • git log 長很醜
  • 強迫症發作
  • 做事沒效率
  • 專案沒進度
  • 人生沒希望

Sublime Merge

刪除 commit

  • soft:改成 staged
  • mixed:改成 modified
  • hard:直接刪除

mixed 結果

WARNING !

git reset --hard 是一個十分危險的指令,一旦使用所有進度都會直接消失。請勿搭配酒精使用,並確認無誤再下達。

回到過去

  • 做了一堆 commit
  • 突然想回到過去看看以前的狀態

Sublime Merge

結果

桌面不乾淨

暫存桌面

  • 現在的檔案不足以 commit
  • 但又需要清空以放下其他東西
  • 暫時存檔之後取出

Sublime Merge

只會儲存 staged

  • stage all
  • stash

結果

取回暫存 - pop

回歸正題

  • 原本想要回到過去被卡住
  • stash 清空桌面
  • pop 之後取回

回到過去

結果

回到現在

修改歷史?

  • 先回到過去
  • 在歷史紀錄上做不同的修改
  • 甚至做出 commit...

結果

Detached HEAD

  • 目前 HEAD 所在的位置不屬於任何 branch
  • 離開後不容易找到這些 commits
  • 謹慎使用 
  • 先不要碰

Demo

把桌面清空 回到第一個 commit 再回來並回復桌面

Review

  • 查看歷史 git log
  • 刪除歷史 git reset
  • 暫存桌面 git stash
  • 回到過去 git checkout

查看歷史

$ git log

刪除歷史

  • $ git reset <commit_ID>
  • $ git reset --soft <commit_ID>
  • $ git reset --hard <commit_ID>

回到過去 - 未清空

暫存桌面

  • $ git add --all
  • $ git stash

只會暫存 staging area

取回暫存

$ git stash pop

回到過去

  • $ git checkout <commit_ID>

回到現在

  • $ git checkout master

git checkout

  • git checkout 其實更常用於切換分枝(branch)
  • 其中 master 就是分支名稱
  • 有時視情況會是 main / 其他名稱
  • 下半堂會更詳細介紹

Demo

$ 指令 time

隱藏資訊

.gitignore

隱藏資訊

  • 機密檔:secret_key.txt
  • 執行檔:a.exe a.out
  • 系統檔:.vscode/ .DS_Store
  • 暫存檔:a.txt~ b.txt.swp

小心再小心

  • 每次都不要 add 到那些檔案
  • 不能使用 git add --all
  • git status 很醜
  • 強迫症發作
  • ...

.gitignore

  • 名稱就叫 ".gitignore"
  • 告訴 git 哪些檔案 / 資料夾底下不要紀錄
  • 不會被 git status / add  看到

.gitignore

secret_key.txt

*.exe
*.out

.vscode/
.DS_Store

*~
*.swp

Sublime Merge

指令

$ vim .gitignore

結果

影響範圍

  • .gitignore 可以每個資料夾底下都放一個
  • 影響範圍會因為位置而有所不同
  • 若撰寫前該檔案已 tracked 則不受影響
  • 可透過 git rm --cached <file> 變回 untracked

Demo & Lab

Task: 撰寫 .gitignore 使 git 無法追蹤 secret_key.txt

Q & A

$ git checkout break_time !

Git 進階

關於分工合作

分工 = branch

合作 = merge 

A

B

版本      

  +修改   

     =版本

b

A

B

E

C

D

F

G

branch

merge

g \(\approx\) e + f

b

e

c

f

d

g

branch:分化出不同的分支完成不同的部分


merge:將不同分支的內容整合在一起

branch

some rules:

  • 一個 branch 指向一個 commit
  • 同個 commit 可以有很多分支,但 Git 同時只會關注(checkout)一個分支
  • 每次 commit ,當前的 branch 也會跟著往後移動

A

git checkout main









B

main

A

git checkout main
git commit -m "C"








B

C

main

A

git checkout main
git commit -m "C"
git commit -m "D"







B

C

main

D

A

git checkout main
git commit -m "C"
git commit -m "D"
git checkout <commit ID of B>






B

C

HEAD

main

D

A

git checkout main
git commit -m "C"
git commit -m "D"
git checkout <commit ID of B>
git checkout -b dev





B

C

dev

main

D

A

git checkout main
git commit -m "C"
git commit -m "D"
git checkout <commit ID of B>
git checkout -b dev
git commit -m "E"




B

C

dev

main

D

E

A

git checkout main
git commit -m "C"
git commit -m "D"
git checkout <commit ID of B>
git checkout -b dev
git commit -m "E"
git checkout main



B

C

dev

main

D

E

A

git checkout main
git commit -m "C"
git commit -m "D"
git checkout <commit ID of B>
git checkout -b dev
git commit -m "E"
git checkout main
git commit -m "F"


B

C

dev

main

D

E

F

A

git checkout main
git commit -m "C"
git commit -m "D"
git checkout <commit ID of B>
git checkout -b dev
git commit -m "E"
git checkout main
git commit -m "F"
git checkout dev

B

C

dev

main

D

E

F

A

git checkout main
git commit -m "C"
git commit -m "D"
git checkout <commit ID of B>
git checkout -b dev
git commit -m "E"
git checkout main
git commit -m "F"
git checkout dev
git commit -m "G"

B

C

dev

main

D

E

F

G

merge

some rules:

  • A 併進 B ≠ B 併進 A
  • A 併進 B:將 branch A 的資訊帶回 B
  • 「合併」就可能發生「衝突」(兩邊的意見不合)

main

main

dev

main

dev

main

dev

merge!

int stack[100], top = 0;
void push(int x){
}
int pop(int x){
}
int stack[100], top = 0;
void push(int x){
  stack[top++] = x;
}
int pop(){
}
int stack[100], top = 0;
void push(int x){
}
int pop(){
  return stack[--top];
}
int stack[100], top = 0;
void push(int x){
  stack[top++] = x;
}
int pop(){
  return stack[--top];
}

A

git checkout main

B

C

dev

main

D

E

F

D

F

A

git checkout main
git merge dev

B

C

dev

main

D

E

F

G

G

Merge this?

main

dev

main

dev

?

conflict!

conflict

  • 兩邊對相同的地方做不同的修改

  • Git 無法自動判斷哪邊該留下

  • 需要手動合併

int gcd(int a, int b){
<<<<<<< HEAD
    while(b != 0){
        int tmp = b;
        b = a % b;
        a = tmp;
    }
    return a;
=======
    return a % b == 0
         ? b
         : gcd(b, a % b);
>>>>>>> rec
}
int gcd(int a, int b){
}
int gcd(int a, int b){
    while(b != 0){
        int tmp = b;
        b = a % b;
        a = tmp;
    }
    return a;
}
int gcd(int a, int b){
    return a % b == 0
         ? b
         : gcd(b, a % b);
}
int gcd(int a, int b){
    return a % b == 0
         ? b
         : gcd(b, a % b);
}
<<<<<<< HEAD
合併前的檔案內容
=======
合併進來的分支的檔案內容
>>>>>>> 合併進來的分支名稱

Git 進階

關於異地同步

Sync strategy

push & pull

pull

push

「嘿,現在的版本樹長怎樣」

「嘿,我做了某某某修改」

git pull

for synchronization

A

B

dev

main

C

D

E

F

A

B

dev

main

C

D

E

A

B

dev

main

C

D

E

F

git fetch

A

B

dev

main

C

D

E

F

A

B

dev

main

C

D

E

A

B

origin/dev

origin/main

C

D

E

F

git fetch
git merge

A

B

dev

main

C

D

E

F

A

B

dev

main

C

D

E

A

B

origin/dev

origin/main

C

D

E

F

 = git pull

git pull

some rules:

  • (fetch)會將所有遠端的 commit 都抓下來
  • (merge)只會 merge 當前的 branch
  • 有 merge 就可能有 conflict 發生

A

B

dev

main

C

D

E

F

A

B

dev

main

C

D

E

G

origin/dev

A

B

main

dev

C

D

E

F

main

C

dev

G

A

B

origin/main

C

D

E

F

A

B

main

C

D

E

F

main

C

G

A

B

origin/main

C

D

E

F

H

dev

dev

origin/dev

git push

for contribution

A

B

dev

main

C

D

E

A

B

dev

main

C

D

E

F

A

B

dev

main

C

D

E

F

A

B

main

C

D

E

A

B

dev

main

C

D

E

F

git push origin dev

A

B

dev

main

C

D

E

F

git push <remote> <branch>

some rules:

  • 將本地的 branch 併進遠端的 branch
  • 只會動到指定的 branch
  • 必須是 fast-forward,否則會被 reject
  • remote 永遠是對的
git push origin dev

A

B

main

C

D

E

F

dev

A

B

main

dev

C

D

E

G

To github.com:username/reponame.git
 ! [rejected]        dev -> dev (fetch first)
error: failed to push some refs to 'github.com:username/reponame.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

E

F

origin/dev

G

dev

H

E

F

origin/dev

G

dev

E

F

dev

pull

merge

push

E

G

dev

E

F

G

dev

H

> git push origin dev
To github.com:username/reponame.git
 ! [rejected]        dev -> dev (fetch first)
error: failed to push some refs to 'github.com:username/reponame.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

> git pull
...
Auto-merging <filename>
CONFLICT (content): Merge conflict in <filename>
Automatic merge failed; fix conflicts and then commit the result.

> git add .

> git commit -m "merged"

> git push

Demo & Lab

Task:合作開發 1A2B 猜數字遊戲

background

  • 三個人一組

  • 透過 Git / GitHub 協作開發猜數字遊戲

For each group...

A

B

C

1.

創建 GitHub 帳號 & 新增 ssh key

github.com/signup

github.com/settings/ssh/new

A

B

C

2.

創建 repository

右上角 + 號

> New Repo

> Repo name 隨便取

> Create repo

A

B

C

3.

新增協作者

Repo 主頁

> Settings

> Collaborators

> Add people

A

B

C

local

ssh-keygen
cat ~/.ssh/id_rsa.pub

github

4.

把 repo clone 到本機上

github.com/username/repo-name

A

B

C

git@github.com:username/repo-name.git
cd /path/to/any/directory
git clone <repo_url>
cd repo-name

5.

A 先打個基本設定

A

B

C

*.exe
.gitignore
#include<stdio.h>
#include<stdlib.h>

int main()
{

}
main.c
git add .
git commit -m "template"
git push origin main

5

main

6.

BC 把 A 的修改 pull 下來

A

B

C

git pull

5

main

7.

B 強迫症發作,改了 A 的 coding style

A

B

C

#include<stdio.h>
#include<stdlib.h>

int main(){

}
main.c
git add .
git commit -m "coding style!!"
git push origin main

5

main

7

main

8.

C 寫了一些遊戲基本流程

A

B

C

#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>

#define len 4

void generate(char *s){
    // TODO
}

void input(char *s){
    // TODO
}

void response(char *s, char *a){
    // TODO
}

int main()
{
    char ans[10000], str[10000];
    generate(ans);

    while(true){
        input(str);
        response(str, ans);
    }
    printf("You won!\n");
}
main.c
git add .
git commit -m "game flow"
git push origin main

5

7

main

To github.com:username/repo-name.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'github.com:username/repo-name.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
void response(char *s, char *a){
    // TODO
}

<<<<<<< HEAD
int main()
{
    char ans[10000], str[10000];
    generate(ans);
=======
int main(){
>>>>>>> <commit ID>

    while(true){
        input(str);
        response(str, ans);
git pull
void response(char *s, char *a){
    // TODO
}

int main(){
    char ans[10000], str[10000];
    generate(ans);

    while(true){
        input(str);
        response(str, ans);
git add .
git commit -m "merge!"
git push origin main

C

5

7

origin/main

8

main

8

5

7

origin/main

8

main

5

7

main

5

7

8

8

main

pull

merge

push

5

8

main

9.

分工:

A 維護 main

B 處理 input

C 處理 output

A

B

C

5

7

8

8

main

input

output

git pull
git checkout -b "input"

B

C

git pull
git checkout -b "output"

10.

A 簡單加上了分隔線

A

B

C

#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>

#define len 4

void generate(char *s){
    // TODO
}

void input(char *s){
    // TODO
}

void response(char *s, char *a){
    // TODO
}

int main(){
    char ans[10000], str[10000];
    generate(ans);

    while(true){
        printf("==========\n");
        input(str);
        response(str, ans);
    }
    printf("You won!\n");
}
main.c
git add .
git commit -m "add separator"
git push origin main

5

7

8

8

main

input

output

10

main

11.

B 完成了數字生成和使用者輸入

A

B

C

#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<time.h>
#include<string.h>

#define len 4

bool valid(char *s){
    if(strlen(s) != len) return false;
    for(int i = 0; i < len; i++){
        for(int j = 0; j < len; j++){
            if(i != j && s[i] == s[j]){
                return false;
            }
        }
    }
    return true;
}

void generate(char *s){
    printf("Set the answer (0 for auto generation): ");
    scanf("%s", s);
    if(s[0] == '0' && strlen(s) == 1){
        srand(time(0));
        while(true){
            for(int i = 0; i < len; i++){
                s[i] = '0' + rand() % 10;
            }
            s[len] = '\0';
            if(valid(s)){
                break;
            }
        }
    }else{
        if(!valid(s)){
            printf("Invalid input\n");
            generate(s);
        }else{
            printf("\033[1A\x1b[2K");
        }
    }
}

void input(char *s){
    printf("Guess a 4-digit number: ");
    scanf("%s", s);
}

void response(char *s, char *a){
    // TODO
}

int main(){
    char ans[10000], str[10000];
    generate(ans);

    while(true){
        input(str);
        if(!valid(str)){
            printf("Invalid input\n");
        }else{
            response(str, ans);
        }
    }
    printf("You won!\n");
}
main.c
git add .
git commit -m "finish input"
git push origin input

5

7

8

8

input

output

10

main

11

input

12.

C 完成回覆函數和勝利判斷

A

B

C

#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<string.h>

#define len 4

void generate(char *s){
    // TODO
}

void input(char *s){
    // TODO
}

bool win(char *s, char *a){
    return strcmp(s, a) == 0;
}

void response(char *s, char *a){
    int cnt_a = 0, cnt_b = 0;
    for(int i = 0; i < len; i++){
        if(s[i] == a[i]){
            cnt_a++;
        }else{
            for(int j = 0; j < len; j++){
                if(s[i] == a[j]){
                    cnt_b++;
                    break;
                }
            }
        }
    }
    if(cnt_a) printf("%dA", cnt_a);
    if(cnt_b) printf("%dB", cnt_b);
    printf("\n");
}

int main(){
    char ans[10000], str[10000];
    generate(ans);

    while(true){
        input(str);
        if(win(str, ans)){
            break;
        }else{
            response(str, ans);
        }
    }
    printf("You won!\n");
}
main.c
git add .
git commit -m "finish output"
git push origin output

5

7

8

8

10

main

11

input

12

output

output

13.

身為 main branch 的維護者, A 要負責將 feature branches 合併進 main

A

B

C

git pull
git merge origin/input

5

7

8

8

10

main

11

input

12

output

13

main

14.

身為 main branch 的維護者, A 要負責將 feature branches 合併進 main

A

B

C

git merge origin/output

5

7

8

8

10

11

input

12

output

13

main

#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
<<<<<<< HEAD
#include<time.h>
=======
>>>>>>> output
#include<string.h>

#define len 4

bool valid(char *s){
    if(strlen(s) != len) return false;
    for(int i = 0; i < len; i++){
        for(int j = 0; j < len; j++){
            if(i != j && s[i] == s[j]){
                return false;
            }
        }
    }
    return true;
}

void generate(char *s){
    printf("Set the answer (0 for auto generation): ");
    scanf("%s", s);
    if(s[0] == '0' && strlen(s) == 1){
        srand(time(0));
        while(true){
            for(int i = 0; i < len; i++){
                s[i] = '0' + rand() % 10;
            }
            s[len] = '\0';
            if(valid(s)){
                break;
            }
        }
    }else{
        if(!valid(s)){
            printf("Invalid input\n");
            generate(s);
        }else{
            printf("\033[1A\x1b[2K");
        }
    }
}

void input(char *s){
    printf("Guess a 4-digit number: ");
    scanf("%s", s);
}

bool win(char *s, char *a){
    return strcmp(s, a) == 0;
}

void response(char *s, char *a){
    int cnt_a = 0, cnt_b = 0;
    for(int i = 0; i < len; i++){
        if(s[i] == a[i]){
            cnt_a++;
        }else{
            for(int j = 0; j < len; j++){
                if(s[i] == a[j]){
                    cnt_b++;
                    break;
                }
            }
        }
    }
    if(cnt_a) printf("%dA", cnt_a);
    if(cnt_b) printf("%dB", cnt_b);
    printf("\n");
}

int main(){
    char ans[10000], str[10000];
    generate(ans);

    while(true){
        printf("==========\n");
        input(str);
<<<<<<< HEAD
        if(!valid(str)){
            printf("Invalid input\n");
=======
        if(win(str, ans)){
            break;
>>>>>>> output
        }else{
            response(str, ans);
        }
    }
    printf("You won!\n");
}

14.

身為 main branch 的維護者, A 要負責將 feature branches 合併進 main

A

B

C

git add .
git commit -m "merge output"

5

7

8

8

10

11

input

12

output

13

main

14

main

5

7

8

8

10

11

input

12

output

13

14

main

git push origin main
gcc game.c -o game.exe
./game.exe
  • 更系統化的分工合作

  • 一個團隊的紀律

  • avoid conflicts!!

5

7

8

8

10

11

input

12

output

13

14

main

大家都想在 main  branch 上做事

\(\Rightarrow\) 衝突多、merge 多、歷史記錄亂

每個人在自己的 branch 上做事

彼此不互相干擾

  • 同一個 branch 盡量少人操作
  • main 作為正式版本
  • merge 進主要分支前要 code review

Feature Branching 

後話

git rebase

A

B

C

A

B

C

A

B

C

B

C

A

B

C

git merge
git rebase

A

B

C

A

B

C

A

B

C

B

D

A

B

C

git merge
git rebase
git merge
git rebase

A

B

C

B

D

A

B

C

  • commit D 幾乎沒有任何意義
  • merge 會產生多餘的 commit
  • merge 會造成非線性的歷史紀錄
  • rebase 就是將 C 的修改重新作用在 B 上面
  • rebase 兩邊的 commit 鏈越長,就容易產生越多的 conflict
  • rebase 對版本樹結構影像較大

Git

By thomaswang2003

Git

  • 533