본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

참조는 작업 트리를 앞질러 가지 않아

~13 min · version-control, concurrency, convergence, failure-modes

Level 0젖은 흙
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

"브랜치"라고 부르는 셋

체크아웃 하나엔 상태가 셋 따로 들어 있어. 참조는 브랜치가 가리키는 자리야. 인덱스는 스테이징된 내용이고. 작업 트리는 디스크 위의 파일들이야. 평소 쓰는 명령은 셋을 한꺼번에 움직이니까 대부분은 이 셋이 따로 노는 걸 볼 일이 없어. 배관 명령은 하나씩 따로 움직여. 일부러 그래. 세 강의 앞의 임시 인덱스 착지가 가능했던 게 바로 그 덕분이고. 착지를 이웃들로부터 격리해준 그 자유가 이 강의 주제야. 그 이웃들이 일하던 체크아웃을 착지가 망가뜨린 경로도 바로 그거였거든.

아무도 안 쓴 스테이징된 되돌림

예전 뒤처짐 경우를 따라가봐. 원격엔 이 체크아웃에 없는 커밋이 있었어. 그래서 착지는 원격 끝 위에 커밋을 빚고, 비교 후 교체로 참조를 거기로 옮겼어. 단계 하나하나는 다 맞았어. 이제 끝난 뒤 체크아웃을 봐. 참조는 다른 기계의 커밋을 쥐고 있는데, 공유 인덱스랑 작업 트리는 그 커밋들 이전 파일을 여전히 들고 있어. 그 체크아웃에서 일하는 모든 세션 눈에는 다른 기계의 작업이 그걸 되돌리는 스테이징된 변경으로 보여. 그리고 누구든 다음에 평범하게 커밋하면 그 되돌림이 그 사람 이름으로 기록돼.

실제로 그대로 재현됐어. 다른 기계가 1 에서 2 로 바꾼 파일이 참조에선 2, 작업 트리에선 1 이었고, 인덱스엔 1 로 되돌리는 변경이 스테이징돼 있었어. 같은 베이스를 쓰던 공개 동사는 더 나빴어. 퀘스트 기록을 낡은 작업 트리에서 읽어서 원격 끝 위에 커밋했거든. 다른 기계가 찍은 도장이 사라졌고, 기록 이력엔 둘 다가 아니라 새 항목 하나만 남았어.

실패한 작업은 하나도 없었어. 오류도 하나 안 찍혔어. 상태가 앞뒤가 안 맞았을 뿐이야. 이 트랙 첫 강의가 기계 셋이 각자 자기 자리에선 맞다고 했던 그 모양이지. 다만 이번엔 그 엇갈림을 작업장이 직접 만들어냈어.

셋 다 옮기든가, 아무것도 안 옮기든가

지금 규칙은 짧아. 참조는 작업 트리가 쥔 것보다 앞으로 절대 안 가. 체크아웃이 뒤처져 있으면 착지는 뭘 쓰기 전에 체크아웃 자체를 빨리 감기해. 빨리 감기 전용 병합은 참조, 인덱스, 파일을 한꺼번에 옮겨. 상관없는 편집이나 스테이징된 조각은 건드리지 않고 그대로 데려가고. 로컬 편집을 덮어쓰게 되면 아무것도 안 옮기고 거부해. 거부되면 착지는 아무것도 안 쓴 채로 멈추니까 나중에 다시 돌려도 안전해. 그 거부가 지켜준 편집은 다른 세션 작업이고, 그걸 어떻게 할지는 사람이 정할 일이야. 빨리 감기가 끝나면 베이스는 그냥 체크아웃 자기 끝이야.

푸시 경합에서 진 경우도 같은 규칙을 따라. 예전 복구는 새 원격 끝 위에 커밋을 다시 빚고 참조만 옮겼어. 같은 버그를 한 걸음 늦게 되풀이한 거지. 지금은 브랜치가 착지의 부모로 한 걸음 물러서고(퀘스트 편집은 여전히 스테이징된 채 디스크에 있어), 작업 트리와 함께 새 끝까지 빨리 감기하고, 거기서 다시 커밋해. 체크아웃이 못 따라갈 때도 있어. 빨리 감기가 누군가의 편집을 덮어쓰게 되거나, 체크아웃에 원격에 없는 커밋이 있을 때. 그러면 브랜치는 착지로 돌아가고, 그 착지는 커밋됐지만 푸시는 안 된 걸로 기록돼. 자기 기록을 쓰기 전에 빠져나가는 복구는 그게 막으려던 구멍보다 나빠.

엔진의 트리는 저자의 트리가 아냐

같은 시즌에 같은 실수가 다른 얼굴로 두 번 더 나왔어. 프로세스가 볼 수 있는 체크아웃이 곧 중요한 체크아웃이라고 가정하는 실수.

원격에 못 간 브리프. 엔진은 자기가 쓰는 브리프를 전부 기계 하나에 있는 자기 체크아웃에 커밋해. 다른 기계에서 몇 초 먼저 푸시된 착지 때문에 엔진의 푸시가 졌어. 로그 줄엔 "푸시 안 됨"이 남았는데 큐 명령은 성공을 찍었어. 그 체크아웃은 이미 갈라져 있어서, 이후의 브리프 푸시도 전부 똑같이 실패할 상황이었고. 이제는 거부된 푸시를 임시 인덱스에서 원격 끝 위로 다시 얹어서 푸시해. 엔진 체크아웃 자기 브랜치는 안 옮긴 채로. 작업 트리 없이 그 참조를 옮기면 거기서도 똑같은 되돌림이 스테이징될 테니까. 그래도 원격에 못 간 게 남으면, 브리프를 쓰는 동사마다 어느 기계인지랑 고치는 명령을 경고로 찍어.

트랙을 못 본 리뷰. 트랙별로 쪼갠 리뷰 요청은 엔진 체크아웃 목록을 보고 범위를 잡았었어. 다른 기계에서 쓴 퀘스트는 착지하기 전까진 그 저자 체크아웃에만 있어. 그래서 엔진은 트랙을 하나도 못 보고 거부했어. 진화 작업이었으면 새 레슨이 리뷰에서 조용히 빠졌을 거고. 지금은 요청하는 기계가 퀘스트의 파일 이름을 보내. 이름만, 아무것도 안 열고. 트랙이 없는 목록은 엔진의 더 오래된 사본으로 메우지 않고 거부해. 요청서를 조립하고, 파일로 쓰고, 커밋하는 건 여전히 엔진이니까 단일 기록자는 그대로 하나야. 자기 트리가 저자의 트리라는 가정만 그만둔 거야.

같은 대상을 서술하는 상태가 전부 옮겨지기 전까지는 옮긴 게 끝난 게 아냐. 하나만 옮길 수 있으면, 나머지가 안 옮겨졌다는 걸 기록해. 어느 상태가, 어느 기계에서, 그리고 그걸 따라오게 만드는 명령이 뭔지. 작업 트리를 앞질러 간 참조는 체크아웃이 뒷받침 못 하는 주장이고, 그걸 믿는 세션은 전부 그 거짓말을 물려받아.

Code

예전 뒤처짐 착지 뒤에 다른 세션들이 본 것·text
the other machine changed shared.txt from 1 to 2 and pushed.
this checkout was behind; the landing built on the remote tip
and moved the ref alone (update-ref).

            ref (HEAD)    index      worktree
shared.txt      2           1           1

$ git status
Changes to be committed:
        modified:   shared.txt        <- staged: 2 -> 1

nobody typed this. the next ordinary `git commit` in this checkout
records it, and the other machine's change is reverted under
somebody else's name.
체크아웃을 데려가든가, 쓰기 전에 멈추든가·python
def converged_base(repo, branch, own_paths):
    """Choose the base AND bring the checkout to it - never one without
    the other. Every exit below happens before anything is written."""
    run(["git", "fetch", "origin"], cwd=repo)
    local = rev(repo, branch)
    remote = rev(repo, f"origin/{branch}", missing_ok=True)
    if remote is None or local == remote:
        return local                                    # level
    if is_ancestor(repo, local, remote):                # BEHIND
        if changed(repo, local, remote, own_paths):
            stop("the remote moved THIS unit's paths - reconcile by hand")
        ff = run(["git", "merge", "--ff-only", "-q", remote],
                 cwd=repo, check=False)  # ref + index + worktree, together
        if ff.returncode != 0:
            stop("fast-forward would overwrite a local edit - another "
                 "session's work. nothing written; re-run once it converges")
        return remote                   # now the checkout's own tip
    if is_ancestor(repo, remote, local):
        return local                                    # AHEAD
    stop("diverged - reconcile by hand")                # DIVERGED


def recover_lost_push(repo, branch, landing, new_remote, paths, msg):
    parent = rev(repo, f"{landing}^")
    update_ref(repo, branch, new=parent, old=landing)   # step back; the
                                                        # edit stays staged
    if not fast_forward(repo, new_remote):              # cannot follow?
        update_ref(repo, branch, new=landing, old=parent)
        return landing, False     # committed, NOT pushed - and RECORDED
    rebuilt = scoped_commit(repo, new_remote, paths, msg)
    update_ref(repo, branch, new=rebuilt, old=new_remote)
    return rebuilt, push(repo, branch)


# The line this replaced, in the BEHIND branch:
#     return remote   # build on the remote tip; move the ref alone
# It chose the right base and left the checkout behind it.

External links

Exercise

연습용 디렉터리에 베어 저장소 하나랑 복제본 둘을 만들어. 복제본 B 에서 파일 하나를 바꾸고 푸시해. 복제본 A 에선 당기지 말고, 예전 착지가 한 그대로 임시 인덱스에서 commit-tree 로 origin 끝 위에 커밋을 빚고 update-ref 로 브랜치를 옮겨. A 에서 git status 를 돌리고 뭐가 스테이징됐다고 나오는지 읽어봐. 그다음 실험을 되돌리고 이번엔 git merge --ff-only 부터 하고 다시 해. 두 번째 실행이 첫 번째는 안 한 걸 뭘 했는지 한 문장으로 적어.
Hint
GIT_INDEX_FILE 은 셸 전체가 아니라 커밋을 빚는 명령들한테만 줘. 그 변수가 그 명령들보다 오래 살아 있으면 git status 가 진짜 인덱스 대신 임시 인덱스를 읽어서, 훨씬 더 헷갈리는 세 번째 상태를 보여줄 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.