나 혼자 쓰는 프로젝트에서만 깃을 쓰다가 같이 쓰는 프로젝트에 commit을 하다 보니 규칙을 잘 이해해야 한다고 생각이 들었고,
규칙을 잘 이해하기 위해서는 헷갈리는 개념을 다시 정리해야 될 필요가 있다고 느꼈다.
깃이 꼬이는 것을 방지하기 위해 기초 개념부터 다시 정리하기로 했다.
깃은 일반적으로 HEAD, Index, 워킹 디렉토리, 이 세 가지 트리를 관리하는 시스템이다.
마지막 commit 스냅샷이자 다음 commit의 부모 commit 역할을 한다.
현재 branch를 가리키는 포인터이며, branch에 담긴 commit 중 가장 마지막 commit을 가리킨다.
HEAD는 항상 작업트리의 가장 최근 커밋을 가리킨다.
작업트리에 변화를 주는 git 명령어들은 대부분 HEAD를 변경하는 것으로 시작한다.
.git/index
파일)엄밀히 말하면 트리구조는 아니고 하나의 파일로 구현되었지만 설명을 쉽게 하기 위해 트리구조라고 말한다고 한다.
Index는 다음에 commit할 스냅샷이며 현재 브랜치가 추적 중인 모든 파일을 기록/관리한다.
아래에 나올 staging area와 관련이 있다.
Index는 워킹 디렉토리에서 마지막으로 checkout 한 branch의 파일 목록과 파일 내용으로 채워진다.
이후 파일 변경작업을 하고 변경한 내용으로 Index를 업데이트 할 수 있다.
이렇게 업데이트하고 git commit
명령을 실행하면 Index는 새 commit으로 변환된다.
아래와 같은 명령어로 index 내부 내용을 볼 수 있다.
샌드박스 역할을 한다.
위의 두 트리는 파일과 그 내용을 효율적인 형태로 .git
디렉토리에 저장하지만 사람이 알아보기 어렵다.
하지만 워킹 디렉토리는 실제 파일로 존재해서 사용자가 편집하기 수월하게 도와준다.
(내가 과제를 위해 만든 git 구조)
(실제 git 내부 구조)
git은 content-addressable 파일 시스템이다.
즉, Key-Value 중심의 데이터 저장소라는 것이다.
이를 위해 git은 변경사항을 저장하는 것이 아니라, 파일 전체를 저장한다. 마치 사진 찍는 것과 비슷해서 스냅샷 저장 방식이라고 한다. 기존에는 diff를 저장했었는데, 이 방식보다 성능상 유리하기 때문에 이러한 방식을 쓴다고 한다.
따라서 저장 공간을 많이 차지하게 되고, 객체들을 압축한 바이너리 파일(*.pack
)과 무엇이 압축되었는지 기록한 index
파일이 별도로 존재한다.
git의 객체로는 오직 3가지의 객체(불변, no update)만 있다(사실 tag라는 객체도 있지만 git의 내부 동작을 이해하는데 중요한 객체는 아니므로).
커밋을 하면 아래와 같은 순서로 생긴다.
Blob은 Binary Large Object의 약자로 git이 관리하는 파일 각각의 내용을 깃의 저장소에서 Blob 형태로 저장한다.
파일을 고치면 Blob이 바뀌는 것이 아니라 새로 생기며, SHA(해시)를 통해 파일 내용을 식별한다.
만약 같은 해시값을 가진다면 같은 파일이라고 간주하며 파일명을 저장하지는 않는다.
예전에는 해시 알고리즘으로 SHA1을 사용했으나 현재는 충돌과 보안 때문에 SHA256을 쓴다고 한다.
커밋당 하나 이상의 트리 객체를 포함한다. 파일 시스템의 디렉터리와 유사하며, 트리는 서브 트리 또는 Blob 객체를 포함한다. Tree 객체에는 커밋 시점의 파일들 각각에 대해 그 파일명과 해당 파일의 내용을 담고 있는 Blob 파일의 주소(해시값) 등이 기록된다.
Commit 객체는 일종의 스냅샷으로 부모 커밋에 대한 참조이다. 실제 깃에는 커밋만 존재하고 히스토리를 위한 별도의 자료 구조가 존재하지 않는다. 하나의 버전을 생성한다는 것은 하나의 Commit 파일을 만드는 것을 의미한다. 이 파일에는 가리키고 있는 Tree 객체의 주소(해시값)와 이전 버전에 해당하는 Commit 파일의 주소(해시값), author, commiter, commit 메시지 등이 기록된다.
Branch - 특정 Commit에 대한 참조일 뿐이다. .git/refs/heads
를 보면 각 Branch 이름으로 파일이 존재하며 해당 파일 내에는 HEAD로 가리키고 있는 Commit 객체의 해시값이 있다.
아래 사진은 main
Branch에서 한 번 Commit한 뒤 aa
라는 Branch를 만들고 찍은 사진과, aa
Branch에서 하나를 추가적으로 Commit했을 때 찍은 사진이다.
(aa에서 추가 커밋 전)
(aa에서 추가 커밋 후)
Push - local에 있는 현재 Branch에서 remote에 없는 커밋을 remote로 동기화하는 명령이다. git push origin <branch>
를 하면 origin/HEAD가 가리키는 부분 변경된다.
+) 추가적으로 직접 테스트를 해봤는데 커밋을 되돌려도 git의 객체들이 사라지진 않고, 해당 시점에 저장된 객체를 통해서 그 상태로 복원(?)하는 것이다.
(first.txt
를 만든 뒤 첫 번째 커밋)
(second.txt
를 만든 뒤 두 번째 커밋)
(두 번째 커밋 자세히)
(hard reset하여 second.txt
는 삭제되었지만 .git/objects
내부에는 남아있음)
깃 상태는 크게 tracked, untracked 상태로 나뉘고, tracked 상태는 다시 modified, unmodified, staged 상태로 나뉜다.
untracked 상태는 git에서 형상 관리를 하지 않는 상태이다.
track을 하고 싶으면 add를 하면 된다.
modified 상태는 tracked 된 상태에서 파일이 변경되었다는 의미이다.
즉, 형상관리에 들어간 파일이 commit 이후에 새로운 변화가 생기면 modified 상태가 된다.
반대로 unmodified 상태는 commit 이후에 새로운 변화가 없는 상태를 말한다.
stage 상태는 commit 전 상태라고 생각하면 와닿을 것이다.
형상관리에 들어가지 않았던 파일이나 들어갔지만 modified 상태의 파일을 대상으로 git add하면 되는 상태이다.
file -> (git add) -> staged -> (git commit) -> unmodified
위와 같은 그림이 내가 가장 이해할 수 있는 그림인 것 같다.
https://www.lainyzine.com/ko/article/git-stash-usage-saving-changes-without-commit/ 이 주소에 더 자세히 나와있다.
이전에는 수정된 코드를 아직 commit하지 않은 상태일 때, 임시 저장을 하지 않는다면 변경사항 전체를 하드 리셋하거나,
저장소를 하나 더 클론 받아오거나 급하게 변경된 부분까지만 commit하는 방법을 써야 됐다고 한다.
이러한 문제를 해결하기 위해 나온 것이 git stash이다.
commit은 하지 않지만 변경된 부분을 저장하기 위해 존재한다.
git stash는 임시 저장을 하는 명령어로 git stash save
의 약어이다.
git stash -m 'message'
로 저장할 수도 있고 stash를 보기 위해서는 git stash list
를 하면 된다.
git stash pop
은 임시 저장된 내용을 꺼내오면서 삭제하는 명령어이다.
git apply(꺼내옴) git drop(stash에서 삭제)
두 명령어가 합쳐진 것이라 생각하면 된다.
임시 저장한 뒤에 다시 같은 파일에(같은 줄에) 어떤 작업을 하고, git stash pop을 하면 어떻게 될까?
conflict가 난다.
이를 해결하기 위해서 conflict를 하나하나 해결하는 merge와 비슷한 일을 해야 된다.
불필요한 commit을 남기지 않는 이유가 가장 큰 것 같다.
이와 비슷한 방식으로 임시적으로 commit을 남긴 뒤, 다음 commit에서 git commit --amend
명령어를 사용함으로써 이전 commit 내용을 수정할 수 있다.
동작은 같지만 commit tree 관리에 차이가 생긴다.
merge는 분기된 branch가 합쳐지는 graph가 나타난다.
반면 rebase는 해당 위치를 기준으로 분기 위치를 다시 잡게 된다.
rebase를 하면 commit도 새로 쓰고(commit 해시도 달라짐) commit 히스토리 정렬도 새로되기 때문에 기존의 commit트리가 달라진다.
자세한 차이는 https://brunch.co.kr/@anonymdevoo/7,
https://dongminyoon.tistory.com/9 여기에 그림과 같이 잘 정리되어 있다.
# branch의 base를 master로 변경
git checkout branch
git rebase master
# conflict가 나면 해결한 뒤에 rebase 진행
git rebase --continue
# rebase를 하기 이전으로 돌리려면
git rebase --abort
만약 서로 다른 branch가 같은 파일, 같은 줄을 동시에 바꾸게 되어 merge 등의 행위를 못 하는 경우 'conflict'가 났다고 표현한다.
그 conflict를 해결하는 것을 resolve한다고 한다.
conflict를 해결하기 위해서는 하나를 제외하고 나머지 branch들의 내용을 포기해야 할 수도 있고, 모든 변경 사항을 받아들일 수도 있다.
이러한 과정을 강제로 병합시키지 않는 이상 손수 하나하나 찾아가며 바꾸는 것이 일반적으로 맞다.
resolve를 하기 위한 방법 중 commit을 되돌림으로써 해결하는 방법에는 3가지 정도가 있다.
기본적으로 reset은 branch를 이동시키는 checkout과 달리, HEAD가 가리키는 commit을 옮긴다.
git reset --soft
옵션을 주면 HEAD를 이전 commit으로 돌린다. 즉, git add
는 되어 있다.
git reset --mixed
옵션을 주면 Index를 업데이트 이전으로 돌린다. 즉, git add
이전 상태가 된다.
git reset --hard
옵션은 working directory를 이전으로 돌린다. 변경된 내용이 로컬에서도 삭제되는 것이므로 조심히 써야 되는 옵션이다.
reset이 HEAD의 위치를 돌려주는 명령어였다면, revert는 commit의 내용을 되돌리는 commit을 만드는 명령어이므로 좀더 안전하다고 볼 수 있다.
사용하는데 약간 불편한 점이 있다면, 가장 최근의 commit부터 revert하지 않으면 conflict가 날 수 있다는 것이다.
# 바로 직전의 commit을 되돌림
git revert HEAD
# 특정 commit의 내용을 되돌림. 이때 conflict가 날 수도 있음
git revert [COMMIT_ID]
# 여러 commit을 한꺼번에 되돌림
git revert [COMMIT_ID1] [COMMIT_ID2] ...
# 여러 commit을 단일 commit으로 revert
git revert -n [COMMIT_ID1] [COMMIT_ID2] ...
# revert commit 메시지 편집하지 않기
--no-edit
# merge commit으로 되돌리기. 어느 commit으로 되돌릴지 알아야 되기 때문에 옵션을 사용하지 않으면 에러 반환
git revert -m <git show에서 보여지는 Merge: 뒤의 값들에 차례대로 부여된 번호 이용> [COMMIT_ID]
이 방법은 쉽지만 트리가 지저분해질 수 있다는 단점이 있다.
방법은 다음과 같다.
되돌릴 commit을 대상으로 branch를 생성한다.
생성된 branch로 checkout한 뒤 되돌릴 부분에 대해서 작업을 진행한다.
변경된 내용을 토대로 기준 branch에 merge한다.
케이크 위의 체리만 쏙 빼먹는, 얌체 같은 사람을 체리피커라고 한다.
이와 비슷한 의미로 사용되는 깃 명령어이다.
commit을 다른 branch에 잘못 하거나, 요구 사항이 바뀌어 필요 없는 commit이 생기거나, 코드 의존성 때문에 다른 사람의 commit 중 일부를 가져와야 하는 경우에 rebase나 cherry-pick의 방법을 사용할 수 있다.
cherry-pick이 그렇게 권장되는 명령어는 아니지만 rebase의 번거로움(다른 branch에서 commit을 가져오고 싶다면 먼저 그 branch를 현재 branch로 merge한 후 rebase)를 감수하기 싫다면 사용하기 좋은 방법이라고 한다.
git cherry-pick <commit hash>
을 하면 해당 commit의 내용을 현재 작업중인 branch로 가져온다.
git cherry-pick <commit hash1>...<commit hash2>
을 하면 해당 commit들 사이에 있는 모든 commit들을 가져온다.
다만 이때 잘못 사용하면 커밋 하나하나에 대해서 싹 충돌을 해결해야 한다.
cherry-pick을 사용해도 당연히 conflict가 일어날 수 있다.
conflict를 해결한 뒤에 git cherry-pick -continue를 사용하면 다시 cherry pick이 진행된다.
git cherry-pick -abort를 사용하면 cherry pick을 하기 전 상태로 돌아간다.
cf) vs git clone
차이 사실 별 거 없다.
The key difference between clone and fork comes down to how much control and independence you want over.
fork는 github(원격) 계정에서 수행된다.
레포지토리를 fork할 때 원본 레포지토리의 복사본을 생성하지만 레포지토리는 github 계정에 남아 있다.
반면 clone는 git(로컬)을 사용하여 수행된다.
레포지토리를 clone하면 레포지토리의 복사본이 로컬에 복사된다.
fork된 레포지토리에 대한 변경 사항을 원본 레포지토리에 반영하고 싶다면 PR을 날려야 한다.
반면 clone으로 받은 내용에 대해서는 원격 저장소에 직접 푸시할 수 있다.
사진 출처: github
upstream은 사실 origin의 다른 명칭이라고 생각할 수 있다.
다른 사람의 레포지토리를 fork한 경우 내 레포지토리가 origin이 된다.
이 때 다른 사람의 레포지토리를 upstream 이라고 부른다.
그저 origin과 구분하기 위해 upstream이라는 명칭을 주로 사용해서 명령어도 동일하다.
git remote add upstream <url>
(add origin과 똑같다)
upstream의 변경 내역을 내 로컬(push하면 원격)에도 반영해야 될 경우가 있다.
git remote # origin과 upstream 두 개가 존재
git fetch upstream
git merge upstream/main # 만약 이 때 충돌이 발생하면 다른 방법도 똑같이 해결하면 된다
git push # origin에도 upstream의 내용이 반영
cf) vs git pull
간단히 요약하면 git pull
은 git fetch
+ git merge FETCH_HEAD
이다.
fetch는 remote에 대해서 추적하는(ref/remotest/<remote>
) 브랜치들에 대해 업데이트한다.
ref/heads
즉, working directory에 대해서는 변경이 되지 않는다.
반면 pull은 ref/heads
에 대해서도 변경이 된다.
따라서 fetch 명령어가 보통 더 안전하며 권고된다.
Git fetch sees all of the remote’s changes without applying them
Git pull is a more advanced action and it’s important to understand that you will be introducing changes and immediately applying them to your currently checked out branch.
~따로 정리해야 될 내용일 줄 알고 소제목 하나로 팠건만... 허무~
https://www.zdnet.com/article/github-to-replace-master-with-alternative-term-to-avoid-slavery-references/ 에 따르면 그냥 표현을 '중립적으로(neutral)'하게 바꾸려는 시도였고 대안으로 'default/primary' 등도 나왔다고 한다.
Git이 새롭게 활성화되기 시작하는 10년전 쯤에 Vincent Driessen이라는 사람의 블로그 글에 의해 널리 퍼지기 시작한 컨벤션이다.
~너무 흔한 사진이라서 출처를 모르겠다.~
git flow를 한 눈에 설명할 수 있는 그림이다.
master 브랜치에서 시작
동일한 브랜치를 develop에도 생성. 개발자들은 이 develop 브랜치에서 개발을 진행
개발을 진행하다가 기능 구현이 필요할 경우 A는 develop 브랜치에서 feature 브랜치를 하나 생성해서 구현하고 B개발자도 develop 브랜치에서 feature 브랜치를 하나 생성해서 구현
완료된 feature 브랜치는 검토를 거쳐 다시 develop 브랜치에 합침(merge)
이제 모든 기능이 완료되면 develop 브랜치를 release 브랜치로 만듦. QA(품질검사)를 하면서 보완점을 보완하고 버그를 픽스
모든 것이 완료되면 이제 release 브랜치를 master 브랜치와 develop 브랜치로 보냄. master 브랜치에서 버전추가를 위해 태그를 하나 생성하고 배포.
배포를 했는데 미처 발견하지 못한 버그가 있을 경우 hotfixes 브랜치를 만들어 긴급 수정 후 태그를 생성하고 바로 배포.
크게 5가지 브랜치가 있다.
1. master : 정식 배포되는 안정적인 버전의 소스 코드가 관리된다. master 브랜치에는 태그가 추가되어 각 릴리즈 버전별로 소스 코드를 빠르게 확인할 수 있다. master 브랜치에는 바로 배포해도 될 만큼 안정성이 충분히 검증된 코드들만 병합되어야 한다.
2. develop : 버그들을 수정하기 위한 코드와 새로운 기능을 추가하기 위한 코드, 성능을 개선하기 위한 코드들이 검증이 완료되고 PR 요청을 거치게 되면 이곳으로 병합된다. 개발자는 feature 브랜치에서 소스 코드를 수정한 다음 deveolop 브랜치로 PR 요청을 하게 된다. 새로운 기능을 위한 feature 브랜치는 develop 브랜치의 HEAD에 생성된다.
3. feature : 새로운 기능 개발이나 버그 수정을 위한 일련의 코드 수정이 이뤄지는 브랜치이다. feature 브랜치에서 작업된 내용은 PR을 거쳐 develop 브랜치에 병합된다.
4. release : git으로 관리되는 소프트웨어는 정기적으로 성능 개선, 기능 추가, 버그 수정을 반영하면서 릴리즈된다. 이러한 릴리즈를 하기 위한 목적의 브랜치이다. 테스트 이후 릴리즈 브랜치의 코드가 안정적이라고 판단되면, master 브랜치에 병합되고 릴리즈에 해당하는 태그가 생성된다.
5. hotfix : 정기적인 릴리즈 이외에 긴급하게 수행되어야 할 버그 수정을 반영하기 위한 브랜치이다. 다음 릴리즈 프로세스를 기다릴 수 없을 정도로 긴급할 때 사용되며 master 브랜치를 HEAD 삼아 생성된다.
release 브랜치가 별도로 존재하지 않아 버전이 준비되면 바로 배포가 가능하다.
아래 사진과 같이 흐름도 훨씬 간편하다.
master 브랜치의 모든 코드는 배포할 수 있는 최신 작업 버전
새로운 작업을 위해서는 브랜치가 master로부터 뻗어져 나와야 함
코드 변경 사항은 가능한 한 자주 로컬 브랜치에 커밋되어야 함. 또한 원격 서버도 이 변경 사항이 가능한 자주 동기화되어야 함
master 브랜치에 병합하려면 PR을 날려야 함
브랜치 안정성 검토를 위해 테스트 환경에 미리 배포되어야 함
최신 릴리즈 패치는 master 브랜치의 최신 코드에서 생성됨
hotfix도 master 브랜치에서 생성되고 해결 뒤 master 브랜치로 병합됨
https://marklodato.github.io/visual-git-guide/index-en.html
https://cselabnotes.com/kr/2021/03/31/56/
https://git-scm.com/book/ko/v2/Git-%EB%8F%84%EA%B5%AC-Reset-%EB%AA%85%ED%99%95%ED%9E%88-%EC%95%8C%EA%B3%A0-%EA%B0%80%EA%B8%B0
https://www.lainyzine.com/ko/article/git-stash-usage-saving-changes-without-commit/