GitSnapFS exposes snapshots of a Git repository as a read-only filesystem designed for safe inspection, automated audits, and tooling integration.
commits/<revspec> presents the tree for an individual commit; annotated tags
and other revspecs are peeled to their commit (commits/v1.0 works). Likewise
trees/<revspec> peels to a tree, so trees/HEAD names HEAD's root tree.branches/ and tags/ mirror the ref hierarchy: refs/heads/feature/foo
appears as branches/feature/foo, with intermediate directories, and each
ref is a symlink into commits/ (or trees/ for tag objects pointing at
trees). HEAD is a symlink into commits/.100755 blobs are executable (0o555),
100644 blobs are 0o444, 120000 blobs are symlinks. Submodule gitlinks
appear as empty directories.. and .. to the kernel, letting path caches stay in userspace.NO_OPEN_SUPPORT, NO_OPENDIR_SUPPORT) for near-native performance once data is cached.gix can only inflate a blob in its entirety, but the kernel asks for file data
in readahead-sized chunks. Instead of keeping a userspace cache, the first
read of a file inflates the blob once, replies with the requested chunk, and
pushes the whole blob into the kernel page cache via FUSE_NOTIFY_STORE. Two
details are load-bearing: the push runs on a dedicated notifier thread
(pushing from the request-handling thread deadlocks against the folio locks
of in-flight reads; see tests/mount_read.rs), and it is issued in segments
from the back of the file forwards, so it meets the kernel's front-to-back
readahead wave head-on instead of trailing it (measured on an 8 MiB file:
8 daemon reads instead of 46). Combined with FOPEN_KEEP_CACHE (the kernel
default under zero-message open), subsequent reads — same pass, later opens,
mmap — are served entirely from the page cache, and eviction under memory
pressure is the kernel's call. GitSnapFS retains no blob data of its own; a
dropped push merely means the kernel keeps asking chunk by chunk, each
answered by a full re-inflation.
Inodes are stateless: the top nibble tags the inode class (directory, regular
blob, executable blob, symlink blob, gitlink, ref-tree node), and the low 60
bits are the object id's leading 15 hex digits (or a name hash for ref nodes).
Folding the tree-entry mode into the inode means the same blob reachable as
both 100644 and 100755 gets two distinct inodes, so attributes are always
coherent. See src/inode.rs for the full layout.
Truncating object ids to 60 bits admits birthday collisions (probability
≈ n²/2⁶¹ for n objects, i.e. ~4×10⁻⁵ at 10 million objects). Collisions are
detected, not silent: inodes resolve through the object database's
unambiguous-prefix lookup, so a collision surfaces as EIO plus an error log
rather than another object's content. In addition, each object entry carries
object-id bits 64..128 in the FUSE generation field: the kernel treats
(nodeid, generation) as the inode identity, so on a collision it evicts the
stale inode instead of serving one object's cached pages or attributes as the
other's, and NFS file handles for the evicted object return ESTALE.
Enumerating commits/ or trees/ is deliberately unsupported and returns
ENOTSUP.
EXPORT_SUPPORT, ZERO_MESSAGE_OPEN, and ZERO_MESSAGE_OPENDIR.fusermount/fusermount3 (typically provided by fuse packages).cargo, rustc).cargo run -- --repo path/to/.git --mountpoint /tmp/gitfs
The mount exposes the root layout (commits, branches, tags, HEAD). Unmount with:
fusermount -u /tmp/gitfs # or fusermount3 -u
Or by just terminating the process.
Design notes live in codex_spec.md.
Formatting and linting are enforced with the following commands:
cargo fmt
cargo clippy --all-targets --all-features -- -D clippy::pedantic -D clippy::style -D clippy::cargo
The clippy.toml documents unavoidable duplicate crate versions coming from upstream dependencies.
Please keep the filesystem read-only and avoid libfuse/libgit2 shims; all Git access goes through gix and FUSE plumbing through fuse-backend-rs.
62 commits
Rust
98.6%
Shell
1.4%
GitSnapFS exposes snapshots of a Git repository as a read-only filesystem designed for safe inspection, automated audits, and tooling integration.
commits/<revspec> presents the tree for an individual commit; annotated tags
and other revspecs are peeled to their commit (commits/v1.0 works). Likewise
trees/<revspec> peels to a tree, so trees/HEAD names HEAD's root tree.branches/ and tags/ mirror the ref hierarchy: refs/heads/feature/foo
appears as branches/feature/foo, with intermediate directories, and each
ref is a symlink into commits/ (or trees/ for tag objects pointing at
trees). HEAD is a symlink into commits/.100755 blobs are executable (0o555),
100644 blobs are 0o444, 120000 blobs are symlinks. Submodule gitlinks
appear as empty directories.. and .. to the kernel, letting path caches stay in userspace.NO_OPEN_SUPPORT, NO_OPENDIR_SUPPORT) for near-native performance once data is cached.gix can only inflate a blob in its entirety, but the kernel asks for file data
in readahead-sized chunks. Instead of keeping a userspace cache, the first
read of a file inflates the blob once, replies with the requested chunk, and
pushes the whole blob into the kernel page cache via FUSE_NOTIFY_STORE. Two
details are load-bearing: the push runs on a dedicated notifier thread
(pushing from the request-handling thread deadlocks against the folio locks
of in-flight reads; see tests/mount_read.rs), and it is issued in segments
from the back of the file forwards, so it meets the kernel's front-to-back
readahead wave head-on instead of trailing it (measured on an 8 MiB file:
8 daemon reads instead of 46). Combined with FOPEN_KEEP_CACHE (the kernel
default under zero-message open), subsequent reads — same pass, later opens,
mmap — are served entirely from the page cache, and eviction under memory
pressure is the kernel's call. GitSnapFS retains no blob data of its own; a
dropped push merely means the kernel keeps asking chunk by chunk, each
answered by a full re-inflation.
Inodes are stateless: the top nibble tags the inode class (directory, regular
blob, executable blob, symlink blob, gitlink, ref-tree node), and the low 60
bits are the object id's leading 15 hex digits (or a name hash for ref nodes).
Folding the tree-entry mode into the inode means the same blob reachable as
both 100644 and 100755 gets two distinct inodes, so attributes are always
coherent. See src/inode.rs for the full layout.
Truncating object ids to 60 bits admits birthday collisions (probability
≈ n²/2⁶¹ for n objects, i.e. ~4×10⁻⁵ at 10 million objects). Collisions are
detected, not silent: inodes resolve through the object database's
unambiguous-prefix lookup, so a collision surfaces as EIO plus an error log
rather than another object's content. In addition, each object entry carries
object-id bits 64..128 in the FUSE generation field: the kernel treats
(nodeid, generation) as the inode identity, so on a collision it evicts the
stale inode instead of serving one object's cached pages or attributes as the
other's, and NFS file handles for the evicted object return ESTALE.
Enumerating commits/ or trees/ is deliberately unsupported and returns
ENOTSUP.
EXPORT_SUPPORT, ZERO_MESSAGE_OPEN, and ZERO_MESSAGE_OPENDIR.fusermount/fusermount3 (typically provided by fuse packages).cargo, rustc).cargo run -- --repo path/to/.git --mountpoint /tmp/gitfs
The mount exposes the root layout (commits, branches, tags, HEAD). Unmount with:
fusermount -u /tmp/gitfs # or fusermount3 -u
Or by just terminating the process.
Design notes live in codex_spec.md.
Formatting and linting are enforced with the following commands:
cargo fmt
cargo clippy --all-targets --all-features -- -D clippy::pedantic -D clippy::style -D clippy::cargo
The clippy.toml documents unavoidable duplicate crate versions coming from upstream dependencies.
Please keep the filesystem read-only and avoid libfuse/libgit2 shims; all Git access goes through gix and FUSE plumbing through fuse-backend-rs.
62 commits
Rust
98.6%
Shell
1.4%