Skip to content

Split View: git이 느려질 때 — 대형 저장소를 빠르게 만드는 구조적 처방

✨ Learn with Quiz
|

git이 느려질 때 — 대형 저장소를 빠르게 만드는 구조적 처방

들어가며 — status 한 번에 12초

클론에 40분이 걸리고, 브랜치를 바꾸면 커피를 타러 갈 수 있고, 상태 확인 한 번에 12초가 지나갑니다.

$ time git status
On branch main
nothing to commit, working tree clean

real	0m12.417s
user	0m1.902s
sys	0m9.884s

이 상태에서 사람들이 가장 먼저 시도하는 것은 검색 상단의 처방입니다. 얕은 클론으로 받고, 정리 명령을 강하게 돌리고, 큰 파일은 LFS로 옮깁니다. 셋 다 상황에 따라 맞는 처방이지만, 원인을 구분하지 않고 적용하면 대개 아무것도 빨라지지 않고 되돌리기만 어려워집니다.

저장소가 느려지는 이유는 크게 세 갈래입니다. 커밋 이력이 긴 것, 추적하는 파일 수가 많은 것, 큰 바이너리가 들어 있는 것. 셋은 서로 다른 병목을 만들고 처방도 겹치지 않습니다.

먼저 측정한다 — 세 갈래를 구분하는 법

가장 빠른 진단은 객체 통계입니다.

$ git count-objects -vH
count: 1042
size: 8.31 MiB
in-pack: 2841903
packs: 14
size-pack: 6.42 GiB
prune-packable: 0
garbage: 0
size-garbage: 0 bytes

여기서 팩 크기가 커밋 수에 비해 지나치게 크다면 바이너리를 의심합니다. 팩 개수가 두 자리라면 정리가 밀린 것입니다. 이제 나머지 두 축을 셉니다.

$ git rev-list --count HEAD
84213

$ git ls-files | wc -l
  212847

커밋 8만 개는 요즘 하드웨어에서 크게 문제가 되지 않습니다. 반면 워킹 트리의 파일 21만 개는 상태 확인이 12초 걸리는 이유로 충분합니다. 상태 확인은 기본적으로 추적 중인 모든 경로에 대해 파일 정보를 확인하기 때문입니다.

큰 객체가 어디에 있는지도 직접 셀 수 있습니다.

$ git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | awk '$1 == "blob"' | sort -k3 -n -r | head -5
blob 4d7a2146 104857600 design/hero.psd
blob 91c0ffee  87031808 assets/renders/intro-01.mov
blob 2b7d13aa  73400320 assets/renders/intro-02.mov
blob a04e1f39  52428800 vendor/sdk/ios-framework.zip
blob 77bc09d1  41943040 design/board-v3.psd

어느 명령이 어디서 시간을 쓰는지까지 보고 싶다면 내장 추적을 켭니다.

$ GIT_TRACE2_PERF=1 git status 2>&1 | grep -E 'region|data' | tail -20

측정 없이 고르는 처방은 대체로 가장 부작용이 큰 것을 고릅니다. 순서는 항상 세는 것이 먼저입니다.

받아 오는 양을 줄인다 — 얕은 클론과 부분 클론은 대체재가 아니다

가장 흔한 조언이 얕은 클론입니다.

$ git clone --depth=1 https://github.com/example/monorepo.git

전송량은 확실히 줄지만, 이 클론에는 이력이 없습니다. 그 결과 이력에 의존하는 명령이 전부 무력해집니다. 로그는 커밋 하나에서 끝나고, 특정 줄을 누가 언제 왜 바꿨는지 추적할 수 없고, 두 브랜치의 공통 조상을 찾을 수 없어 머지와 리베이스가 실패합니다. 태그 기반 버전 문자열 생성도 동작하지 않습니다. 나중에 이력이 필요해져서 깊이를 늘리는 작업은 처음부터 다시 받는 것보다 서버에 더 무거운 경우도 많습니다.

부분 클론은 다른 물건입니다. 커밋과 트리는 전부 받고 파일 내용만 필요할 때 가져옵니다.

$ git clone --filter=blob:none https://github.com/example/monorepo.git
Cloning into 'monorepo'...
remote: Enumerating objects: 2841903, done.
remote: Counting objects: 100% (2841903/2841903), done.
Receiving objects: 100% (1204418/1204418), 412.55 MiB | 22.31 MiB/s, done.
Resolving deltas: 100% (812004/812004), done.
Updating files: 100% (48211/48211), done.

이쪽은 로그도 이분 탐색도 공통 조상 계산도 전부 정상입니다. 대가는 예전 파일 내용이 필요한 순간에 네트워크를 타는 지연입니다. 오래된 줄의 이력을 추적하는 작업은 눈에 띄게 느려집니다.

용량 기준으로 거르는 중간 형태도 있습니다. 큰 파일만 나중에 받게 하는 방식으로, 별도 서버 없이 바이너리 문제의 상당 부분을 완화합니다.

$ git clone --filter=blob:limit=1m https://github.com/example/monorepo.git
방식줄이는 것그대로 동작하는 것깨지거나 느려지는 것적합한 곳
얕은 클론커밋 이력최신 스냅숏 빌드로그, 줄 단위 이력, 공통 조상, 태그 기반 버전버리는 CI 체크아웃
부분 클론 blob:none파일 내용로그, 이분 탐색, 공통 조상오래된 파일 접근 시 네트워크 지연개발자 클론의 기본값
부분 클론 tree:0트리와 파일 내용커밋 메타데이터 조회경로 지정 로그와 대부분의 diff커밋 정보만 필요한 도구
희소 체크아웃워킹 트리 파일 수이력 전체범위 밖 경로의 편집모노레포에서 한 영역만 볼 때

정리하면 이렇습니다. 얕은 클론은 버릴 체크아웃을 위한 도구이고, 개발자 머신의 기본값으로 권할 만한 것은 부분 클론입니다. 자동화 파이프라인에서도 머지 기준점 계산이 필요하다면 얕은 클론은 이미 잘못된 선택입니다.

작업 트리를 줄인다 — 희소 체크아웃과 파일 감시

파일 수가 병목이라면 클론 방식으로는 해결되지 않습니다. 워킹 트리에 실제로 펼치는 경로를 줄여야 합니다.

$ git clone --filter=blob:none --no-checkout https://github.com/example/monorepo.git
$ cd monorepo
$ git sparse-checkout init --cone --sparse-index
$ git sparse-checkout set services/payments libs/common
$ git checkout main

$ git ls-files | wc -l
    2143

여기서 옵션 두 개가 중요합니다. 콘 모드는 디렉터리 접두어만으로 판정해 매칭 비용을 거의 없앱니다. 옛 문서에 나오는 무시 규칙 스타일의 자유 패턴은 경로마다 패턴 전체를 돌려야 해서, 파일이 많아질수록 오히려 느려집니다. 희소 인덱스 옵션은 인덱스 파일 자체에서 범위 밖 디렉터리를 항목 하나로 접어 둡니다. 워킹 트리만 줄이고 인덱스를 그대로 두면 상태 확인은 여전히 21만 개 항목을 훑습니다.

그다음이 파일 감시입니다. 상태 확인이 느린 근본 원인은 변경 여부를 알아내려고 모든 경로의 정보를 읽는다는 것인데, 운영체제가 변경 알림을 주면 그럴 필요가 없습니다. Git 2.37부터는 별도 도구 없이 내장 감시자가 들어 있습니다.

$ git config core.fsmonitor true
$ git config core.untrackedCache true

$ git status    # 첫 실행은 캐시를 채우느라 느립니다
$ time git status
real	0m0.512s

추적되지 않는 파일 캐시를 함께 켜는 이유는, 감시자가 알려 주는 것은 변경된 경로일 뿐이고 새 파일 탐색은 별도의 디렉터리 순회이기 때문입니다. 둘을 같이 켜야 효과가 납니다.

읽기를 빠르게 한다 — 커밋 그래프와 정리 작업

이력이 길어서 느린 경우의 처방은 따로 있습니다. 로그와 공통 조상 계산은 커밋 객체를 하나씩 열어 부모를 따라가는 작업인데, 이 정보를 미리 계산해 별도 파일에 담아 두면 압축 해제 자체를 건너뛸 수 있습니다.

$ git commit-graph write --reachable --changed-paths
Expanding reachable commits in commit graph: 84213, done.
Writing out commit graph in 4 passes: 100% (336852/336852), done.

$ time git log --oneline -- services/payments | wc -l
    1841
real	0m0.63s

경로를 지정한 로그가 특히 크게 달라집니다. 변경 경로 옵션이 커밋마다 어떤 경로가 바뀌었는지에 대한 확률적 필터를 함께 저장하기 때문에, 대부분의 커밋을 열어 보지도 않고 건너뜁니다.

이런 보조 파일들을 손으로 관리할 필요는 없습니다. Git 2.31부터 유지보수 작업을 예약해 두는 기능이 표준으로 들어왔습니다. 예전처럼 정리 명령을 크론에 걸어 두던 관행을 대체합니다.

$ git maintenance start
$ git config --get-all maintenance.repo
/Users/dev/work/monorepo

여기서 흔한 잘못된 조언 하나를 짚어야 합니다.

# 널리 퍼져 있지만 대개 손해입니다
$ git gc --aggressive

이 옵션은 기존 델타 체인을 전부 버리고 훨씬 큰 탐색 창으로 압축을 처음부터 다시 계산합니다. 수 기가바이트 저장소에서는 CPU를 몇 시간씩 쓰고, 그렇게 얻는 용량 이득은 보통 크지 않습니다. Git이 평소에 만드는 델타가 이미 충분히 좋기 때문입니다. 팩 개수가 많아 조회가 느린 것이 문제라면, 필요한 것은 재계산이 아니라 재배치입니다.

$ git repack --geometric=2 -d --write-midx
$ git multi-pack-index write

기하급수적 재패킹은 작은 팩들만 골라 합치고 큰 팩은 그대로 둡니다. 다중 팩 인덱스는 여러 팩을 하나의 조회 색인으로 묶어 팩 개수가 조회 비용에 미치는 영향을 없앱니다. 정리의 목표는 팩을 하나로 만드는 것이 아니라 조회를 한 번에 끝내는 것입니다.

큰 바이너리 — LFS가 하는 일과 하지 않는 일

디자인 파일이나 영상이 저장소에 들어 있으면 다른 처방이 전부 무력해집니다. 바이너리는 델타 압축이 거의 듣지 않아서, 100메가바이트 파일을 열 번 수정하면 저장소가 1기가바이트씩 자랍니다.

Git LFS가 하는 일은 생각보다 단순합니다. 지정한 경로에 대해 커밋 시점에 파일 내용을 짧은 포인터 텍스트로 바꿔치기하고, 체크아웃 시점에 실제 내용을 별도 서버에서 내려받습니다. Git이 저장하는 것은 언제나 포인터뿐입니다.

$ git lfs track "*.psd"
$ cat .gitattributes
*.psd filter=lfs diff=lfs merge=lfs -text

$ git cat-file -p HEAD:design/hero.psd
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eafe21c1b8e4a6d0f0a2d0b1e6dbf3a9c
size 104857600

여기서 가장 많이 오해되는 지점이 있습니다. LFS를 오늘 도입해도 어제 커밋한 바이너리는 저장소에서 사라지지 않습니다. 이미 만들어진 커밋의 트리는 여전히 원래 블롭을 가리키고, 그 블롭은 팩 안에 그대로 있습니다. 클론 용량은 1바이트도 줄지 않습니다. 과거까지 정리하려면 이력을 다시 쓰는 별도 작업이 필요하고, 그것은 다음 절의 이야기와 같은 대가를 치릅니다.

$ git lfs migrate import --include="*.psd,*.mov" --everything
migrate: Sorting commits: ..., done.
migrate: Rewriting commits: 100% (84213/84213), done.
migrate: Updating refs: ..., done.

한계도 분명히 알아 둘 만합니다. 서버가 LFS를 지원해야 하고 대개 용량과 대역폭에 별도 요금이 붙습니다. 로컬 캐시는 따로 관리해야 합니다. 그리고 LFS는 바이너리를 다른 곳으로 옮길 뿐, 병합할 수 있게 만들어 주지는 않습니다. 두 사람이 같은 디자인 파일을 고치면 충돌은 여전히 사람이 손으로 골라야 합니다. 그 문제를 진짜로 줄이려면 잠금 기능을 쓰거나, 애초에 산출물을 저장소 밖으로 빼는 편이 낫습니다.

$ git lfs prune                       # 오래된 로컬 캐시 정리
$ GIT_LFS_SKIP_SMUDGE=1 git clone ... # 포인터만 받고 나중에 선택적으로 내려받기
$ git lfs pull --include="design/hero.psd"

한 가지 덧붙이면, 앞에서 본 용량 기준 부분 클론은 별도 서버 없이 비슷한 효과의 일부를 냅니다. 새로 시작하는 저장소라면 LFS 도입 전에 이 선택지를 먼저 검토할 만합니다.

이미 커버린 저장소 — filter-repo와 모든 해시가 바뀌는 대가

과거의 바이너리나 잘못 들어간 대용량 파일을 실제로 없애려면 이력을 다시 써야 합니다. 예전 문서에 나오는 filter-branch는 Git 공식 문서 자체가 사용을 만류하고 있고, 지금의 권장 도구는 filter-repo입니다.

$ pip install git-filter-repo

$ git filter-repo --path assets/renders --invert-paths
Parsed 84213 commits
New history written in 132.71 seconds; now repacking/cleaning...
Completely finished after 218.04 seconds.

$ git count-objects -vH
in-pack: 1932450
size-pack: 1.14 GiB

6.42기가바이트가 1.14기가바이트가 됐습니다. 여기까지는 좋은 소식이고, 대가는 지금부터입니다.

이력을 다시 쓴다는 것은 모든 커밋의 부모가 바뀐다는 뜻이고, 부모가 바뀌면 커밋 해시가 전부 바뀝니다. 그 결과는 저장소 하나에 그치지 않습니다.

  • 열려 있는 모든 PR이 존재하지 않는 커밋을 가리키게 됩니다.
  • 태그와 릴리스를 다시 밀어야 하고, 특정 커밋 해시를 고정해 둔 배포 설정과 서브모듈 참조가 전부 깨집니다.
  • 팀원 전원이 새로 클론해야 합니다. 기존 클론에서 받아 오면 옛 이력과 새 이력이 둘 다 들어와 저장소가 두 배가 됩니다. filter-repo가 작업 후 원격 설정을 일부러 지우는 이유가 이것입니다.
  • 서버 쪽에서도 즉시 사라지지 않습니다. 호스팅 서비스에는 PR 참조와 포크가 옛 커밋을 붙들고 있어, 실제 삭제까지는 별도의 정리 요청이 필요한 경우가 많습니다.

그래서 이 작업은 명령이 아니라 계획이 필요합니다. 머지를 잠그고, 시점을 공지하고, 재작성하고, 강제로 밀고, 전원이 다시 클론하고, 오래된 브랜치를 정리하는 순서입니다. 되돌릴 수 없는 처방은 이 글의 처방 중 이것 하나뿐이고, 그래서 마지막에 놓여 있습니다.

마치며 — 처방보다 분류가 먼저입니다

이 글의 명령들을 다 외울 필요는 없습니다. 기억할 것은 순서입니다. 커밋 수와 파일 수와 팩 크기를 먼저 세고, 그 결과에 맞는 처방을 하나씩 적용합니다.

이력이 길어서 느리면 부분 클론과 커밋 그래프입니다. 파일이 많아서 느리면 희소 체크아웃과 파일 감시이고, 이때 인덱스까지 함께 줄이지 않으면 효과가 반으로 떨어집니다. 바이너리가 문제면 LFS나 용량 기준 부분 클론이고, 과거까지 지우려면 이력 재작성이라는 되돌릴 수 없는 결정을 감수해야 합니다.

대부분의 팀에서 가장 큰 이득을 주는 두 줄은 유지보수 예약과 파일 감시를 켜는 것입니다. 부작용이 없고, 되돌리기 쉽고, 체감이 즉시 옵니다. 이력 재작성은 그 두 줄로 해결되지 않는다는 것이 확인된 뒤에 꺼내도 늦지 않습니다.

객체와 팩파일이 실제로 어떻게 생겼는지는 Git 내부 구조로 이해하는 명령어 편에서 이어집니다.

When git gets slow — structural prescriptions for a large repository

Introduction — twelve seconds for one status

The clone takes 40 minutes, switching branches gives you time to make coffee, and one status check burns 12 seconds.

$ time git status
On branch main
nothing to commit, working tree clean

real	0m12.417s
user	0m1.902s
sys	0m9.884s

In this state, the first thing people try is whatever the top search result prescribes. Clone shallow, run the cleanup command hard, move big files to LFS. All three are correct prescriptions in the right situation, but applied without telling the causes apart they usually make nothing faster and only make things harder to undo.

There are broadly three reasons a repository gets slow: a long commit history, a large number of tracked files, and big binaries inside it. The three create different bottlenecks and their prescriptions do not overlap.

Measure first — how to tell the three apart

The fastest diagnosis is object statistics.

$ git count-objects -vH
count: 1042
size: 8.31 MiB
in-pack: 2841903
packs: 14
size-pack: 6.42 GiB
prune-packable: 0
garbage: 0
size-garbage: 0 bytes

If the pack size is wildly out of proportion to the commit count, suspect binaries. If the pack count is in double digits, housekeeping is overdue. Now count the other two axes.

$ git rev-list --count HEAD
84213

$ git ls-files | wc -l
  212847

Eighty thousand commits is not much of a problem on modern hardware. Two hundred and twelve thousand files in the working tree, on the other hand, is more than enough to explain a 12-second status. A status check by default stats every tracked path.

You can also count directly where the large objects are.

$ git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | awk '$1 == "blob"' | sort -k3 -n -r | head -5
blob 4d7a2146 104857600 design/hero.psd
blob 91c0ffee  87031808 assets/renders/intro-01.mov
blob 2b7d13aa  73400320 assets/renders/intro-02.mov
blob a04e1f39  52428800 vendor/sdk/ios-framework.zip
blob 77bc09d1  41943040 design/board-v3.psd

If you want to see which command spends its time where, turn on the built-in tracing.

$ GIT_TRACE2_PERF=1 git status 2>&1 | grep -E 'region|data' | tail -20

A prescription chosen without measuring usually turns out to be the one with the worst side effects. Counting always comes first.

Reduce what you download — shallow clone and partial clone are not substitutes

The most common advice is a shallow clone.

$ git clone --depth=1 https://github.com/example/monorepo.git

Transfer volume certainly drops, but this clone has no history. As a result every command that depends on history is crippled. The log ends at one commit, you cannot trace who changed a given line when or why, and merge and rebase fail because there is no way to find the common ancestor of two branches. Tag-based version string generation stops working too. And deepening the clone later because you turned out to need history is often heavier on the server than fetching from scratch.

A partial clone is a different animal. It fetches every commit and tree and only pulls file contents when they are needed.

$ git clone --filter=blob:none https://github.com/example/monorepo.git
Cloning into 'monorepo'...
remote: Enumerating objects: 2841903, done.
remote: Counting objects: 100% (2841903/2841903), done.
Receiving objects: 100% (1204418/1204418), 412.55 MiB | 22.31 MiB/s, done.
Resolving deltas: 100% (812004/812004), done.
Updating files: 100% (48211/48211), done.

Here the log, bisect and merge-base computation are all normal. The price is the network latency at the moment you need old file contents. Tracing the history of an old line gets noticeably slower.

There is also a middle form that filters by size. Deferring only large files mitigates much of the binary problem without a separate server.

$ git clone --filter=blob:limit=1m https://github.com/example/monorepo.git
ApproachWhat it reducesWhat still worksWhat breaks or slowsWhere it fits
Shallow cloneCommit historyBuilding the latest snapshotLog, per-line history, merge base, tag-based versionsThrowaway CI checkouts
Partial clone blob:noneFile contentsLog, bisect, merge baseNetwork latency when reaching old filesThe default for developer clones
Partial clone tree:0Trees and file contentsReading commit metadataPath-limited logs and most diffsTools that only need commit info
Sparse checkoutFiles in the working treeThe full historyEditing paths outside the scopeLooking at one area of a monorepo

To sum up. A shallow clone is a tool for a checkout you are going to throw away, and the thing worth recommending as the default on a developer machine is a partial clone. Even in an automation pipeline, if merge-base computation is needed, a shallow clone was already the wrong choice.

Reduce the working tree — sparse checkout and a filesystem monitor

If the file count is the bottleneck, no clone strategy will fix it. You have to reduce the paths actually laid out in the working tree.

$ git clone --filter=blob:none --no-checkout https://github.com/example/monorepo.git
$ cd monorepo
$ git sparse-checkout init --cone --sparse-index
$ git sparse-checkout set services/payments libs/common
$ git checkout main

$ git ls-files | wc -l
    2143

Two options matter here. Cone mode decides by directory prefix alone, which removes almost all of the matching cost. The free-form ignore-rule-style patterns from older documents have to run the entire pattern set against every path, which gets slower as the file count grows. The sparse index option folds out-of-scope directories into a single entry inside the index file itself. If you shrink only the working tree and leave the index alone, a status check still walks all 212 thousand entries.

Next comes the filesystem monitor. The root cause of a slow status is that it reads information about every path to find out what changed, and if the operating system tells you about changes there is no need for that. Since Git 2.37 a built-in monitor ships without any separate tool.

$ git config core.fsmonitor true
$ git config core.untrackedCache true

$ git status    # the first run is slow because it fills the cache
$ time git status
real	0m0.512s

The reason to enable the untracked cache alongside it is that the monitor only tells you about changed paths, while discovering new files is a separate directory traversal. You have to turn both on for the effect to land.

Make reads faster — the commit-graph and housekeeping

There is a separate prescription for slowness caused by a long history. Log and merge-base computation both mean opening commit objects one by one and following parents, and if you precompute that information into a separate file you can skip decompression entirely.

$ git commit-graph write --reachable --changed-paths
Expanding reachable commits in commit graph: 84213, done.
Writing out commit graph in 4 passes: 100% (336852/336852), done.

$ time git log --oneline -- services/payments | wc -l
    1841
real	0m0.63s

Path-limited logs change the most. The changed-paths option also stores a probabilistic filter over which paths changed in each commit, so most commits get skipped without ever being opened.

You do not have to manage these auxiliary files by hand. Since Git 2.31, scheduling maintenance tasks is a standard feature. It replaces the old habit of putting the cleanup command in cron.

$ git maintenance start
$ git config --get-all maintenance.repo
/Users/dev/work/monorepo

One piece of common bad advice has to be called out here.

# Widely repeated, but usually a net loss
$ git gc --aggressive

This option throws away every existing delta chain and recomputes compression from scratch with a much larger search window. On a multi-gigabyte repository it burns CPU for hours, and the space it wins back is usually not large, because the deltas Git makes routinely are already good enough. If your problem is that lookups are slow because there are many packs, what you need is not recomputation but rearrangement.

$ git repack --geometric=2 -d --write-midx
$ git multi-pack-index write

Geometric repacking picks only the small packs and merges them, leaving the big ones alone. The multi-pack index binds several packs into one lookup index, removing the effect the pack count has on lookup cost. The goal of housekeeping is not to end up with one pack, it is to finish a lookup in one step.

Big binaries — what LFS does and does not do

Once design files or video live in the repository, every other prescription is crippled. Delta compression barely works on binaries, so editing a 100-megabyte file ten times grows the repository by a gigabyte.

What Git LFS does is simpler than it sounds. For the paths you designate it swaps the file content for a short pointer text at commit time, and downloads the real content from a separate server at checkout time. What Git stores is always just the pointer.

$ git lfs track "*.psd"
$ cat .gitattributes
*.psd filter=lfs diff=lfs merge=lfs -text

$ git cat-file -p HEAD:design/hero.psd
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eafe21c1b8e4a6d0f0a2d0b1e6dbf3a9c
size 104857600

Here is the most misunderstood point. Adopting LFS today does not make the binary you committed yesterday disappear from the repository. The trees of commits that already exist still point at the original blobs, and those blobs are still sitting in the packs. Clone size does not shrink by a single byte. Cleaning up the past requires a separate history rewrite, and that carries the same price as the next section.

$ git lfs migrate import --include="*.psd,*.mov" --everything
migrate: Sorting commits: ..., done.
migrate: Rewriting commits: 100% (84213/84213), done.
migrate: Updating refs: ..., done.

The limits are worth knowing clearly too. The server has to support LFS, and storage and bandwidth usually cost extra. The local cache has to be managed separately. And LFS only moves binaries somewhere else, it does not make them mergeable. If two people edit the same design file, someone still has to pick a side by hand. To really reduce that problem you want file locking, or better, to keep build artifacts out of the repository in the first place.

$ git lfs prune                       # clean up the old local cache
$ GIT_LFS_SKIP_SMUDGE=1 git clone ... # fetch pointers only, download selectively later
$ git lfs pull --include="design/hero.psd"

One thing to add: the size-filtered partial clone above delivers part of the same effect without any separate server. For a repository you are starting fresh, that option is worth evaluating before adopting LFS.

A repository that already grew — filter-repo and the price of every hash changing

To actually remove old binaries or a large file that got in by mistake, you have to rewrite history. The filter-branch that appears in older documents is discouraged by the official Git documentation itself, and the recommended tool today is filter-repo.

$ pip install git-filter-repo

$ git filter-repo --path assets/renders --invert-paths
Parsed 84213 commits
New history written in 132.71 seconds; now repacking/cleaning...
Completely finished after 218.04 seconds.

$ git count-objects -vH
in-pack: 1932450
size-pack: 1.14 GiB

6.42 GiB became 1.14 GiB. That is the good news, and the price starts now.

Rewriting history means the parent of every commit changes, and when the parent changes every commit hash changes. The consequences do not stop at one repository.

  • Every open PR ends up pointing at commits that do not exist.
  • Tags and releases have to be pushed again, and every deployment config and submodule reference pinned to a specific commit hash breaks.
  • Everyone on the team has to clone fresh. Fetching into an existing clone brings in both the old and the new history and doubles the repository. That is exactly why filter-repo deliberately removes the remote configuration after it runs.
  • It does not vanish on the server side either. Hosting services keep old commits alive through PR refs and forks, so actual deletion often requires a separate cleanup request.

So this job needs a plan, not a command. Lock merges, announce the time, rewrite, force-push, have everyone re-clone, and clean up old branches, in that order. This is the only irreversible prescription in this post, which is why it is placed last.

Closing — classification comes before prescription

You do not need to memorize the commands in this post. What you need to remember is the order. Count commits, files and pack size first, then apply the prescription that matches the result, one at a time.

Slow because the history is long means partial clone and commit-graph. Slow because there are many files means sparse checkout and a filesystem monitor, and if you do not shrink the index along with the tree the benefit is cut in half. If binaries are the problem it is LFS or a size-filtered partial clone, and erasing the past means accepting the irreversible decision of a history rewrite.

The two lines that give most teams the biggest win are turning on scheduled maintenance and the filesystem monitor. No side effects, easy to undo, and the difference is immediate. Rewriting history can wait until you have confirmed that those two lines were not enough.

What objects and packfiles actually look like is picked up in Reading Git commands through internals.