Điều gì nếu mỗi commit git chỉ là một thư mục bạn có thể duyệt qua?
Mỗi lần muốn xem một file từ branch khác, tôi lại phải làm cái điệu múa quen thuộc. Mở notes ra. Tìm “git show file trên branch khác”. Tìm thấy câu trả lời Stack Overflow từ năm 2011. Copy lệnh. Đọc nhầm cú pháp dấu hai chấm. Lỗi. Thử lại. Cuối cùng lấy được file, cảm thấy hơi thất bại.
Tôi không tệ môn git. Não tôi chỉ từ chối ghi nhớ git show branch-khác:đường/dẫn/file.go mà thôi.
Julia Evans có vẻ gặp vấn đề tương tự. Và thay vì viết cheatsheet, bà ấy dựng cả một NFS server.
Ý tưởng cốt lõi: commit là thư mục, hãy làm nó thật sự như vậy
Commit git thực sự hoạt động như thư mục. Mỗi commit trỏ đến một tree object - về cơ bản là danh sách đệ quy các file và thư mục con. Lý do duy nhất bạn không thể duyệt ~/.git như filesystem là vì git lưu mọi thứ ở dạng binary objects để tiết kiệm dung lượng đĩa.
git-commit-folders loại bỏ lớp trừu tượng đó. Chạy nó trên một repo và nó mount một filesystem ảo, mỗi commit hash trở thành một thư mục thật:
$ ls commits/8d/8dc0/8dc0cb0b4b0de3c6f40674198cb2bd44aeee9b86/
README
$ ls commits/c9/c94e/c94e6f531d02e658d96a3b6255bbf424367765e9/
_config.yml config.rb Rakefile rubypants.rb source
Branch và tag chỉ là symlink trỏ đến thư mục commit:
$ ls -l branches/
lrwxr-xr-x main -> ../commits/04/043e/043e90debbeb0fc6b4e28cf8776e874aa5b6e673
lrwxr-xr-x feature-x -> ../commits/91/912d/912da3150d9cfa74523b42fae028bbb320b6804f
Giờ thì bài toán “xem file trên branch khác” trở thành:
vim branches/branch-khac/go.mod
Không cần nhớ cú pháp git. Chỉ là đường dẫn file.
--> // making it invisible to querySelectorAll. // // `data-cfasync="false"` keeps this rescue script executable even when // Rocket Loader is active. It rescues module scripts via two strategies: // 1. Query the DOM for type$="-module" + src (covers case A) // 2. Regex-parse the raw HTML for commented-out script tags (covers case B) // Dynamically-created scripts bypass Rocket Loader entirely. (function () { if (window.__markdyRescue) return; window.__markdyRescue = true; var rescued = false; function rescueModuleScripts() { if (rescued) return; rescued = true; var srcs = []; // Strategy 1: Rocket Loader kept the tag in DOM but changed the type. // type="module" → type="{uuid}-module" (still has src attribute) document.querySelectorAll('script[type$="-module"][src]').forEach(function (s) { srcs.push(s.src); }); // Strategy 2: Rocket Loader COMMENTED OUT the script tag entirely: // // These are invisible to querySelectorAll, so we parse the raw HTML. // We handle both attribute orderings (type-first or src-first). var html = document.documentElement.innerHTML; var reSrcFirst = //g; var reTypeFirst = //g; var m; while ((m = reSrcFirst.exec(html)) !== null) { srcs.push(m[1]); } while ((m = reTypeFirst.exec(html)) !== null) { srcs.push(m[1]); } // Re-inject each found src as a real module script. // Deduplicate first, then inject. Dynamically-created scripts bypass // Rocket Loader entirely. Modules with the same URL are only executed // once by the browser (cached), so re-injecting already-running scripts // is safe. var seen = {}; srcs.forEach(function (src) { if (seen[src]) return; seen[src] = true; var fix = document.createElement('script'); fix.type = 'module'; fix.src = src; fix.setAttribute('data-cfasync', 'false'); document.head.appendChild(fix); }); } // Rescue when user clicks the placeholder (fallback if autoplay failed). document.addEventListener('click', function (e) { var t = e.target; if (t && typeof t.closest === 'function' && t.closest('.markdy-placeholder')) { rescueModuleScripts(); } }); // Rescue automatically after a short delay for autoplay. // Only fires if initAll() never ran (no data-markdy-init on any root). setTimeout(function () { if (document.querySelector('.markdy-root:not([data-markdy-init])')) { rescueModuleScripts(); } }, 1500); }());Tại sao cách này lại có ích (dù git grep đã tồn tại)
Julia thẳng thắn thừa nhận đây không phải cách hiệu quả nhất. git show, git log -S, git grep đều có sẵn. Nhưng:
| Tác vụ | Cách git thường | Cách dùng filesystem |
|---|---|---|
| Tìm hàm đã xóa | git log -S 'funcName' --all -- file.go | grep -r funcName branch_histories/main/*/file.go |
| Xem file trên branch khác | git show branch:đường/dẫn/file | vim branches/branch-khac/đường/dẫn/file |
| Tìm kiếm trên tất cả branch | git grep pattern $(git branch -a) | grep -r pattern branches/*/commit.go |
Lệnh git mạnh hơn - có thể lọc theo ngày, tác giả, nội dung patch. Nhưng cách dùng filesystem thì không cần nhớ gì hết. Mọi công cụ bạn đã biết (grep, find, diff, vim) đều hoạt động ngay.
Nhưng kỹ thuật không đơn giản chút nào
Julia muốn cái này chạy được trên macOS mà không cần kernel extension. Điều đó loại FUSE ra khỏi danh sách. Hai giao thức filesystem native còn lại trên macOS là WebDAV và NFS.
Bà thử WebDAV trước. Thư viện chuẩn Go có sẵn WebDAV. Trông khả thi. Rồi gặp bẫy: x/net/webdav dùng io/fs, mà io/fs không hỗ trợ symlink. Branch sẽ phải là thư mục thật thay vì symlink - phá vỡ toàn bộ mental model.
Vậy là chuyển sang NFS. NFSv3 cụ thể, qua thư viện go-nfs.
Kết quả là một nhật ký kỹ thuật sâu 9 vấn đề:
- Vấn đề 3 (liệt kê hàng triệu commit): Tổ chức theo 2 ký tự prefix, giống cách
.git/objectslàm, rồi cache toàn bộ hash commit đã pack vào memory. Hoạt động được trên Linux kernel với ~1 triệu commit. Mất khoảng một phút khởi động lần đầu, sau đó cập nhật nhanh. - Vấn đề 4 (lỗi “Not a directory”): Đây là cách NFS báo bất kỳ lỗi liệt kê thư mục nào, không phải sai kiểu dữ liệu. Phải dùng Wireshark mới chẩn đoán được.
- Vấn đề 5 (inode numbers): Julia vô tình đặt mọi inode thư mục về 0.
findnhận ra khi mọi thư mục có cùng inode và từ chối đệ quy, cho rằng có vòng lặp filesystem - điều này hoàn toàn hợp lý. - Vấn đề 6 (stale file handles): Vẫn chưa giải quyết được. NFS cần map 64-byte opaque handles sang file đúng, nhưng thư viện go-nfs dùng cache cố định bị tràn trên repo lớn.
Đây là điều tôi thích ở cách Julia viết: bà không dọn dẹp đống thất bại. Bài viết đọc như nhật ký debug, và những thứ chưa sửa được vẫn nằm nguyên trong danh sách.
Tại sao HN đào lại bài này vào năm 2026
Người submit (pvtmert) nói thẳng:
“Given the advent of LLMs and agentic coding, I believe this article needs re-visiting as it makes it much more discoverable to compare individual files across commits.”
Đây mới là góc nhìn thú vị. AI coding agent không vất vả với cú pháp git show - nhưng lại gặp khó với các lệnh git plumbing tùy tiện không được biểu diễn kỹ trong training data, hoặc cần parse binary output. Một filesystem thuần túy mà mọi công cụ đều đọc được là interface đơn giản hơn nhiều cho agent đang điều hướng qua lịch sử code.
Thread HN cũng nhắc đến ClearCase (hệ thống version control doanh nghiệp ra đời trước git) có thứ tương tự: bạn có thể truy cập foo.c@@/versions/5 như một đường dẫn thông thường. Fossil cũng có lệnh fusefs làm điều này.
Và một commenter, steveBK123, tóm gọn sự hoài nghi trong ba chữ:
“NFS.. stop right there”
Không phải không có lý. NFS nổi tiếng với những edge case kỳ lạ. Nhưng khi phương án thay thế là FUSE với kext hoặc không-xây-gì-hết, lựa chọn của Julia hoàn toàn ổn.
Nên xem không?
Đọc bài gốc nếu:
- Bạn viết công cụ điều hướng lịch sử git bằng code
- Bạn tò mò về cách object model của git ánh xạ lên filesystem
- Bạn xây AI coding tools và đang nghĩ về cách agent truy cập context code cũ
- Bạn là fan Julia Evans và muốn xem ai đó ghi lại cẩn thận 9 cách NFS làm khó cuộc sống
Bỏ qua nếu:
git showvàgit log -Sđã nằm trong cơ bắp bạn rồi- Bạn cần truy cập lịch sử git cho môi trường production (có công cụ VFS tốt hơn)
- Repo của bạn có hàng triệu commit và bạn không muốn chờ 1 phút mỗi lần khởi động
Dự án này thực nghiệm và Julia nói thẳng điều đó. Nhưng công cụ thực nghiệm giúp bạn hiểu cách thứ gì đó vận hành thường là loại tốt nhất.
Thảo luận trên Hacker News · Nguồn: jvns.ca · Đăng bởi pvtmert
Hoang Yell
Một nhà phát triển phần mềm và là người kể chuyện kỹ thuật. Tôi đọc Hacker News mỗi ngày và kể lại những câu chuyện hay nhất ở đây — bằng tiếng Việt và tiếng Anh, cho người tò mò nhưng không có thời gian.