글을 작성하게 된 계기
Git을 사용하던 중, HEAD에 Lock이 걸렸고, 커밋과 브랜치 이동이 되지 않았습니다. 집에 와서 이를 학습하던 중 알게 된 내용을 정리하기 위해 글을 작성하게 되었습니다.
1. 문제 상황
회사에서 커밋을 하던 중, 시간이 너무 오래 걸려 Ctrl + C로 해당 작업을 중지 했습니다. 이후 다시 커밋을 하니 다음과 같은 에러가 발생했습니다. 로그를 간단히 살펴보면, .git/HEAD.lock 이 존재하기 때문에, 다른 Git 프로세스가 실행 중이라고 판단해 에러를 발생시켰네요.
1
2
3
4
5
6
7
8
This message is shown once a day. To disable it please create the /root/.hushlogin file.
$ git branch * master branch-362 branch-876 refactor/branch-1136 refactor/branch-1137
refactor/branch-1139 refactor/branch-1203 refactor10 $ git switch refactor/branch-1203 error:
Unable to create '/mnt/c/Users/user/company/project/.git/HEAD.lock': File exists. Another
git process seems to be running in this repository, e.g. an editor opened by 'git commit'.
Please make sure all processes are terminated then try again. If it still fails, a git process
may have crashed in this repository earlier: remove the file manually to continue. fatal:
unable to update HEAD
Git은 브랜치를 변경하거나 커밋을 할 때, 내부적으로 .git/HEAD 파일을 수정하며, 이 작업 중 다른 프로세스의 동시 접근을 막기 위해 .git/HEAD.lock 이라는 잠금 파일을 생성합니다. 작업이 정상적으로 완료되면 해당 잠금 파일은 자동으로 삭제 되지만, 중간에 작업이 비정상적으로 종료되면 이 파일이 남게 됩니다. Git은 이전 작업이 아직 진행 중이라고 판단해 에러를 발생시키고요. 즉, 비정상적 종료로 인해 HEAD.lock 파일이 삭제되지 않아 문제가 발생한 것이죠.
- Git은 작업 수행 시 .git/HEAD.lock 파일을 생성합니다.
- 정상 종료 시 이 잠금 파일은 자동으로 삭제됩니다.
- Git 작업 중간에 강제 종료되면 .git/HEAD.lock 파일이 남습니다.
- 다음 Git 작업 시, 해당 파일을 보고 아직 다른 작업이 진행 중이라 오해해 에러와 함께 작업을 중단합니다.
2. 동작 원리
HEAD.lock 파일은 다음과 같은 순서로 생성되는데, 실제 Git 소스 코드를 살펴보겠습니다.
1
2
3
4
hold_lock_file_for_update_timeout_mode( )
└── lock_file_timeout( )
└── lock_file( )
└── create_tempfile_mode( ) ← 여기서 락 파일 생성
2-1. hold_lock_file_for_update_timeout_mode
Git 리소스를 잠그기 위해 가장 먼저 호출되는 함수입니다. 락 파일을 만들기 위해 내부적으로 lock_file_timeout( )을 호출하며, 실패 시 에러 메시지를 출력하거나 프로그램을 종료합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int hold_lock_file_for_update_timeout_mode(struct lock_file *lk,
const char *path, int flags,
long timeout_ms, int mode)
{
int fd = lock_file_timeout(lk, path, flags, timeout_ms, mode);
if (fd < 0) {
if (flags & LOCK_DIE_ON_ERROR)
unable_to_lock_die(path, errno);
if (flags & LOCK_REPORT_ON_ERROR) {
struct strbuf buf = STRBUF_INIT;
unable_to_lock_message(path, errno, &buf);
error("%s", buf.buf);
strbuf_release(&buf);
}
}
return fd;
}
에러는 Git이 락 파일을 만들려고 했는데, 해당 경로에 이미 .lock 파일이 존재할 경우, 즉, 다른 Git 프로세스가 같은 리소스를 작업 중이라고 판단될 때 입니다.
1
2
3
4
5
6
/*
* If a lock is already taken for the file, `die()` with an error
* message. If this flag is not specified, trying to lock a file that
* is already locked silently returns -1 to the caller, or ...
*/
#define LOCK_DIE_ON_ERROR 1
1
2
3
4
5
/*
* ... this flag can be passed instead to return -1 and give the usual
* error message upon an error.
*/
#define LOCK_REPORT_ON_ERROR 4
2-2. lock_file_timeout
이후 .lock 파일을 만들기 위해 lock_file( )을 반복적으로 호출하면서, 이미 락이 존재할 경우 바로 실패하지 않고 재시도합니다. 재시도 간 간격은 지수적으로 증가하며, 각 대기 시간은 0.75배 ~ 1.25배 사이의 무작위(Jitter) 값 을 적용합니다. 이를 통해 여러 Git 프로세스가 동시에 같은 락을 시도할 때 경쟁 조건이나 충돌을 줄일 수 있습니다. timeout_ms 내에 락을 획득하지 못하면 실패(-1)를 반환합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
static int lock_file_timeout(struct lock_file *lk, const char *path,
int flags, long timeout_ms, int mode)
{
int n = 1;
int multiplier = 1;
long remaining_ms = 0;
static int random_initialized = 0;
if (timeout_ms == 0)
return lock_file(lk, path, flags, mode);
if (!random_initialized) {
srand((unsigned int)getpid());
random_initialized = 1;
}
if (timeout_ms > 0)
remaining_ms = timeout_ms;
while (1) {
long backoff_ms, wait_ms;
int fd;
fd = lock_file(lk, path, flags, mode);
if (fd >= 0)
return fd; /* success */
else if (errno != EEXIST)
return -1; /* failure other than lock held */
else if (timeout_ms > 0 && remaining_ms <= 0)
return -1; /* failure due to timeout */
backoff_ms = multiplier * INITIAL_BACKOFF_MS;
/* back off for between 0.75*backoff_ms and 1.25*backoff_ms */
wait_ms = (750 + rand() % 500) * backoff_ms / 1000;
sleep_millisec(wait_ms);
remaining_ms -= wait_ms;
/* Recursion: (n+1)^2 = n^2 + 2n + 1 */
multiplier += 2*n + 1;
if (multiplier > BACKOFF_MAX_MULTIPLIER)
multiplier = BACKOFF_MAX_MULTIPLIER;
else
n++;
}
}
2-3. lock_file
다음으로 lock_file( ) 함수를 호출해 주어진 경로에 .lock 확장자를 붙여 최종 파일 이름을 만들고, 해당 이름으로 임시 락 파일을 생성합니다. 이를 위해 strbuf라는 Git 내부 문자열 버퍼를 사용해 경로를 조립하며, 필요하면 심볼릭 링크도 해석합니다. 생성에 성공하면 파일 디스크립터(fd)를 반환하며, 실패 시 -1을 반환합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int lock_file(struct lock_file *lk, const char *path, int flags,
int mode)
{
struct strbuf filename = STRBUF_INIT;
strbuf_addstr(&filename, path);
if (!(flags & LOCK_NO_DEREF))
resolve_symlink(&filename);
strbuf_addstr(&filename, LOCK_SUFFIX);
lk->tempfile = create_tempfile_mode(filename.buf, mode);
strbuf_release(&filename);
return lk->tempfile ? lk->tempfile->fd : -1;
}
LOCK_NO_DEREF 는 심볼릭 링크를 따라가지 않고, 해당 경로 자체에 .lock 파일을 만들고 싶을 때 사용 합니다. 일반적으로 Git은 경로가 심볼릭 링크일 경우 실제 대상 파일로 해석한 뒤 그 위치에 락을 걸지만, 이 플래그가 설정되면 링크 자체를 그대로 사용해 .lock을 생성합니다. 예를 들어 HEAD가 심볼릭 링크인 경우, 보통은 refs/heads/master.lock에 락을 걸지만, LOCK_NO_DEREF가 설정되면 HEAD.lock 자체를 생성합니다.
1
2
3
4
5
6
7
8
9
10
11
/*
* Usually symbolic links in the destination path are resolved. This
* means that (1) the lockfile is created by adding ".lock" to the
* resolved path, and (2) upon commit, the resolved path is
* overwritten. However, if `LOCK_NO_DEREF` is set, then the lockfile
* is created by adding ".lock" to the path argument itself. This
* option is used, for example, when detaching a symbolic reference,
* which for backwards-compatibility reasons, can be a symbolic link
* containing the name of the referred-to-reference.
*/
#define LOCK_NO_DEREF 2
Git은 일반적으로 HEAD가 가리키는 실제 브랜치 파일(refs/heads/master) 에 접근해 커밋 정보를 수정하거나 업데이트합니다. 이때는 심볼릭 링크를 따라가서 실제 대상 파일에 락을 걸고 작업하는 것이 맞습니다. 하지만 특정 상황에서는 HEAD 파일 자체를 직접 수정해야 합니다. 대표적으로 git checkout --detach 가 있습니다. 이는 특정 커밋으로 이동한 후, 브랜치와의 연결을 끊는 작업인데, 결과적으로 .git/HEAD 파일의 내용이 다음처럼 바뀝니다.
1
2
3
4
5
# 브랜치와 연결이 안 끊긴 경우
ref: refs/heads/main
# 브랜치와 연결이 끊긴 경우
4f5e3f65d2b0c34b524e3f0eaa0cb013fe0e3fc3
즉, 단순 커밋 해시를 기록해 둔 것을 참조하게 되며, 더 이상 어떤 브랜치도 가리키지 않습니다. 이때는 심볼릭 링크를 따라가면 안 되고, HEAD 자체 파일을 직접 수정해야 합니다. 이런 경우는 .git/HEAD에 직접 .lock을 만들어 락을 걸고 작업하는 것이죠. 이 과정에서 LOCK_NO_DEREF 플래그가 필요하고요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.git
├── HEAD → HEAD가 참조하는 커밋 해시(4f5e3f65) - detached
│ │
├── objects │
│ ├── 4f │
│ │ └── 5e3f65... ← HEAD가 참조하는 커밋 객체
│ │
│ └── f8
│ └── a2d4e3... ← main 브랜치가 참조하는 커밋 객체
│ │
└── refs │
└── heads │
└───── main (f8a2d4e3) ──────┘
저장된 커밋 해시
2-4. create_tempfile_mode
락 파일을 실제로 디스크에 생성하는 함수입니다. open(O_CREAT | O_EXCL) 로 파일을 만들고, 실패하면 정리 후 NULL을 반환합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct tempfile *create_tempfile_mode(const char *path, int mode)
{
struct tempfile *tempfile = new_tempfile();
strbuf_add_absolute_path(&tempfile->filename, path);
tempfile->fd = open(tempfile->filename.buf,
O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, mode);
if (O_CLOEXEC && tempfile->fd < 0 && errno == EINVAL)
/* Try again w/o O_CLOEXEC: the kernel might not support it */
tempfile->fd = open(tempfile->filename.buf,
O_RDWR | O_CREAT | O_EXCL, mode);
if (tempfile->fd < 0) {
deactivate_tempfile(tempfile);
return NULL;
}
activate_tempfile(tempfile);
if (adjust_shared_perm(the_repository, tempfile->filename.buf)) {
int save_errno = errno;
error("cannot fix permission bits on %s", tempfile->filename.buf);
delete_tempfile(&tempfile);
errno = save_errno;
return NULL;
}
return tempfile;
}
이 문제는 Windows에서 Git을 사용할 때 특히 더 자주 발생하며, WSL 환경이나 네트워크 드라이브를 사용할 경우에도 파일 잠금 해제가 실패하는 사례가 있습니다. 회사에서는 Window를 메인 PC로 사용하고 있는데, 이래서 발생하지 않았나 싶습니다.
- Git lock files remain after the operation
- git-for-window: Unable to create HEAD.lock when cloning inside directory with permissions in directory without any permissions (#2531)
3. 문제 해결
해결책은 간단합니다. 문제는 .git/HEAD.lock 파일이 남아 있어 Git이 다른 작업 중이라고 착각하는 데서 발생했습니다. 즉, 해당 파일을 수동으로 삭제해 Git이 정상적으로 동작하도록 만들어 주면 됩니다. 🚀
1
$ rm -f .git/HEAD.lock
작업을 가급적 강제 종료 하지 않는게 가장 중요한데요, 이게 불가피한 경우도 있더라고요. LTS 같은 대용량 파일을 추적하고 있는 경우, 원격 저장소에 푸시했을 때, 시간이 너무 많이 걸리다가 타임아웃이 발생하던가 이런 경우 같이요. 최근 알게 된 내용이지만 이 경우도 LFS 병렬 푸시로 해결이 가능하긴 합니다.
1
$ GIT_LFS_CONCURRENT_TRANSFERS=3 git push
4. 정리
Git 작업을 하던 중, 강제로 명령어를 중단했고, 이 과정에서 Lock 파일이 남아 있어 다른 Git 명령어가 실행되지 않았던 이슈였습니다. Lock 파일은 .git 폴더 내에 생성되는데, 이를 제거해주면 해당 이슈를 해결할 수 있습니다. Git은 사용할 수록 참 잘만든 시스템이라는 생각이 드는데요, 이것만 별도로 시간을 내서 공부해보고 싶기도 합니다. 좋은 시스템을 하나씩 잡고 분석하다보면 운영체제의 개념들을 하나씩 알 수 있으니까요. 항상 시간이 부족해서 좀 아쉽네요. 🥲