Portuguese version: pt-br/filesystem-fuse.md
GitDriveFS is the bridge between the FUSE kernel and the versioned content of the Git repository. It maintains a hierarchical model with FileEntry and DirEntry linked by inode references, an atomic allocator for new inodes created at runtime (create/mkdir), and logic extracted into pure apply_* methods that are testable without mounting the kernel. Reads consult the inode's write buffer (if one exists) and fall back to the Git blob at HEAD; mutations update in-memory indexes and write buffers, and enqueue SyncOps to the SyncManager.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E3F2FD','primaryTextColor':'#0D47A1','primaryBorderColor':'#1976D2','lineColor':'#546E7A'}}}%%
flowchart TB
accTitle: GitDriveFS FUSE operations
accDescr: Kernel syscalls reach the trait methods, which delegate to pure apply functions; in-memory indexes, write buffers and the SyncManager queue are the three destinations.
classDef kernel fill:#E3F2FD,stroke:#1976D2
classDef pure fill:#FFF3E0,stroke:#EF6C00
classDef state fill:#F1F8E9,stroke:#558B2F
Kernel[(FUSE kernel)]:::kernel
subgraph Pure["Pure functions (testable without the kernel)"]
Lookup[apply_lookup]:::pure
Meta[apply_getattr / apply_readdir]:::pure
Mut[apply_create / apply_unlink / apply_mkdir / apply_rmdir / apply_rename]:::pure
WriteP[apply_write / apply_truncate / apply_flush]:::pure
end
Idx[indexes: files_by_inode, dirs_by_inode, inodes_by_path]:::state
Buf[write_buffers + dirty flags]:::state
Sync[SyncManager: causal queue]:::state
Git[git.rs: blob at HEAD]:::state
Kernel -- "lookup / getattr / readdir" --> Lookup
Kernel -- "create / unlink / mkdir / rmdir / rename" --> Mut
Kernel -- "write / setattr(size) / flush" --> WriteP
Kernel -- "read" --> Buf
Lookup --> Idx
Meta --> Idx
Mut --> Idx
Mut -- "queue_*" --> Sync
WriteP --> Buf
WriteP -- "queue_write (if dirty)" --> Sync
Buf -- "fallback when no buffer" --> Git
Lookup -. "refresh_if_needed: buffer gc + rebuild" .-> Idx
const TTL: Duration = Duration::from_secs(1);
const ROOT_INODE: u64 = 1;
const FILE_MODE: u16 = 0o644;
const DIR_MODE: u16 = 0o755;struct FileEntry {
inode: u64,
path: String, // "foo/bar/baz.txt"
parent: u64, // inode of the parent directory
size: u64,
}
struct DirEntry {
inode: u64,
path: String, // "" for root
parent: u64, // 0 for root
children: HashSet<u64>,
}next_inode: Arc<AtomicU64> starts at 2 + total_inodes_at_mount. alloc_inode does fetch_add(1, SeqCst). Monotonic — deleted inodes are not recycled. In refresh_if_needed, preserved paths reuse the old inode and allocation for new paths is lazy (unwrap_or_else) — a refresh with no changes does not advance the counter.
- Inserts the root (
inode 1, path""). - Calls
git::list_dirs_at_commitandgit::list_files_at_commit(full paths). - Directories are sorted by depth and inserted from shallow to deep, guaranteeing the parent exists when the child is inserted.
- Files use the same parent-resolution scheme via
path.rfind('/'). - The root is special-cased (empty path → parent is
inodes_by_path[""]).
Each inode with a pending write has a WriteBuffer in write_buffers — the ENTIRE file content, in copy-on-write RAM (Arc shared with the sync queue) below large_file_threshold_mb, or in a spool file above it — and, optionally, a mark in dirty. For a chunked file, the buffer is born from the streaming reassembly (decrypted, if encrypted).
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E3F2FD','primaryTextColor':'#0D47A1','primaryBorderColor':'#1976D2','lineColor':'#546E7A'}}}%%
stateDiagram-v2
accTitle: Write buffer lifecycle of an inode
accDescr: Without a buffer, read falls back to HEAD; the first write creates the buffer from HEAD and marks it dirty; flush enqueues and clears the mark; gc discards clean buffers once HEAD contains the content.
[*] --> NoBuffer: reads go straight to the blob at HEAD
NoBuffer --> Dirty: first write or truncate<br/>(buffer is born as a COPY of the HEAD content)
Dirty --> Clean: flush enqueues the Write op<br/>and clears the mark
Dirty --> Clean: truncate / rename / exchange<br/>enqueue the content directly
Clean --> Dirty: new write
Clean --> NoBuffer: gc — commit_generation advanced<br/>or post-pull refresh
Dirty --> NoBuffer: unlink
Rules that sustain the model:
- Initialization with current content (
ensure_buffer): the buffer is born as a copy of the blob at HEAD. POSIX semantics —writeat an offset modifies only the written range;truncatepreserves the prefix. A buffer born empty zeroed out everything that was not rewritten (corruption in appends, editor dirty pages and truncates). - Per-inode dirty flag:
flushruns on everyclose(2), including fds opened read-only.apply_flushonly enqueuesSyncOp::Writeif the inode is dirty, and clears the mark when enqueuing — without this, a simplecatre-enqueued stale content and could revert a merged remote change. - GC of clean buffers (
refresh_if_needed): whencommit_generationadvances (a drain committed) or on the post-pull refresh, non-dirty buffers are discarded — the content is already at HEAD and keeping them would hide remote changes fromreadforever. Dirty buffers are never touched. - Operations that enqueue directly clear the mark:
apply_truncate, file rename,exchangeand the implicit swap enqueue the current content immediately, so the following flush must not duplicate the op.
| Op | Trait method | Pure function | Behavior |
|---|---|---|---|
lookup |
lookup |
apply_lookup(parent, name) |
Finds the child in dirs_by_inode[parent].children by leaf_name. Refresh first. |
getattr |
getattr |
apply_getattr(ino) |
Returns FileAttr with the process uid/gid and a stable mtime (derived from the last commit that touched the path; session touches take precedence). Refresh first. |
setattr |
setattr |
apply_setattr (apply_truncate for size) |
mode goes to mode_overrides (in-memory); size truncates the buffer preserving content; an explicit mtime updates times (an atime-only setattr is dropped). The reply is apply_getattr — the kernel caches it for the TTL, so it must carry the real size of a chunked file. |
readdir |
readdir |
apply_readdir(ino) |
Lists ., .. and children sorted by name. Refresh only on the first page (offset == 0). |
readdirplus |
readdirplus |
apply_readdir(ino) |
Variant with inline attributes — Finder uses getattrlistbulk. |
open |
open |
— | Always accepts, fh = ino. |
read |
read |
apply_read(ino, offset, size) |
Inode buffer (RAM or spool); chunked files and regular files of at least 4 MiB by range from the working tree (RangeReader, verified once per session); small files from the blob cache or the blob at HEAD via a persistent repo handle (a miss populates the cache). Errors become EIO. |
write |
write |
apply_write(ino, offset, data) |
Buffer initialized from HEAD; write at offset; updates entry.size; marks dirty. |
flush |
flush |
apply_flush(ino) |
Enqueues queue_write only if dirty; clears the mark; touch. |
release |
release |
— | Only logs. |
create |
create |
apply_create(parent, name) |
Allocates an inode, inserts a FileEntry, enqueues queue_write(path, []) to materialize on disk. |
unlink |
unlink |
apply_unlink(parent, name) |
Removes from indexes, buffer and dirty; enqueues queue_delete. EISDIR if it is a directory. |
mkdir |
mkdir |
apply_mkdir(parent, name) |
Allocates an inode, inserts a DirEntry, enqueues queue_mkdir (ephemeral paths such as .Trashes stay in memory only — Finder's native Trash). |
rmdir |
rmdir |
apply_rmdir(parent, name) |
Removes inode + path, enqueues queue_rmdir. ENOTEMPTY if it has children. |
rename |
rename |
apply_rename(parent, name, new_parent, new_name) |
Moves the inode between parents, updates descendant paths, reindexes. renameatx_np flags (patch in vendor/fuser): RENAME_SWAP routes to apply_exchange, RENAME_EXCL returns EEXIST if the destination exists. Directories use queue_rename — except ephemeral endpoints: an ephemeral destination expands into recursive Delete/Rmdir (bottom-up) and an ephemeral source materializes via Mkdir/Write. Files become queue_write(dest) + queue_delete(src), populating a non-dirty buffer and clearing dirty. Empty names (defensive fallback of the old parsing) trigger the implicit swap. |
exchange |
exchange (macOS) |
apply_exchange(parent, name, newparent, newname) |
Swaps the content of two files (TextEdit/iWork safe save). Updates buffers, enqueues both writes, clears dirty. The same logic serves rename with RENAME_SWAP. |
link |
link |
— | Simulated via a copy with a new inode (1 inode = 1 path model). Covers the transient backup of the safe save. |
readlink |
readlink |
apply_readlink(ino) |
Symlink target (content of the 120000 blob). EINVAL for a regular file. |
symlink |
symlink |
apply_symlink(parent, name, target) |
Creates an entry with symlink kind, a buffer with the target text, and enqueues SyncOp::Symlink (a barrier in the causal queue). |
statfs |
statfs |
— | Volume advertised with ~1 TiB free — a signal to userland, not a quota. |
setxattr/getxattr/listxattr/removexattr |
same | — | In-memory HashMap per inode; ENOATTR on macOS, ENODATA on Linux. |
| Error | When |
|---|---|
ENOENT |
Inode/name does not exist in the index |
ENOTDIR |
Parent expected to be a directory is a file |
EISDIR |
File operation applied to a directory (unlink, exchange) |
EEXIST |
create/mkdir/link/rename to an already existing destination (dir) |
ENOTEMPTY |
rmdir on a directory with children |
EINVAL |
Non-UTF8 name |
EIO |
git2 error in read |
EXDEV |
Implicit swap with no counterpart at the destination (defense against ambiguous exchangedata encodings) |
Not implemented (default ENOSYS): mknod, fsync, fallocate, copy_file_range.
src/fs.rs::refresh_if_needed, called at the start of lookup, getattr, readdir/readdirplus (first page):
- If there are pending ops in the sync queue (
has_pending_ops), it returns — a refresh would rebuild the indexes from HEAD, discarding locally created paths that have not reached a commit yet. - If
commit_generationadvanced, discards non-dirty write buffers (see lifecycle above). - Reads-and-clears
refresh_flag; if it wasfalse, returns. - Discards non-dirty buffers, reopens the repo, lists dirs + files at HEAD.
- Preserves inodes of paths that still exist; for new paths, allocates via
next_inode(lazy). Paths that disappeared lose their inode.
TTL = 1s in the kernel limits the effective frequency of these calls.
apply_read in src/fs.rs (the read callback delegates). Source order:
- Write buffer of the inode (pending mutations, post-create/rename content not yet committed).
- Chunked file (manifest detected by
manifest_for):read_chunkedreads, per chunk, only the requested range of the chunk file throughRangeReader, checking each chunk's SHA-256 against the manifest once per session. Mismatch =EIO. - Regular file of at least 4 MiB (
RANGE_READ_MIN_BYTES):read_worktree_rangereads the range from the working-tree copy, checked once against the blob id at HEAD (plus the AEAD tag when sealed). A copy that is not the HEAD version falls back to step 5; a bad tag isEIO. Local-only files of that size are read by range without a check (they are outside Git). - Blob cache (
BlobCache): LRU keyed by inode with a byte budget (read_cache_mbinconfig.json, default 64 MiB;0disables). A blob larger than the budget does not enter. - Blob at HEAD: reads through the persistent repo handle; the miss populates the cache.
Steps 2 and 3, the verification rules and the measured numbers are detailed in large-file-reads.md.
Cache invalidation: full clear at the buffer gc points (commit_generation advance and
post-pull refresh — HEAD changed); per-inode removal on unlink, write, truncate and file
rename. create and file rename populate a non-dirty buffer with the current content — without
this, reading the new path before the commit hit a blob that did not exist at HEAD and returned EIO.
- 1s TTL in the kernel masks changes coming from a pull for up to 1 second.
- No FIFOs / real hardlinks (
linkis a copy). Symlinks are supported: opaque target, never resolved (no-follow); an absolute target breaks on another machine, just like in plain Git. - Permissions and xattrs in memory —
chmodis accepted but does not persist across mounts;chownis ignored. - mtime derived from history — a file untouched in the session exposes the timestamp of the last commit that touched the path (single revwalk with a 4096-commit ceiling; the residue falls back to the HEAD timestamp). Session touches (flush/setattr) take precedence and do not persist across mounts.
atime/ctime/crtimemirror the available mtime, so an atime-only setattr is dropped. - Uncommitted writes are lost on a crash before the debounce drains.
- The implicit swap requires exactly one child file in the source directory — ambiguity returns
EXDEVand the app degrades to the copy flow. - Files of 4 MiB or more are read from the working tree — by range, verified once per session; they bypass the blob cache. When the working-tree copy is not the HEAD version, reads fall back to loading the whole blob from the object store (slow for large files; a warning is logged).
- Write buffers above
large_file_threshold_mblive in a spool (.git/git-drive-spool/) with random access — RAM peak bounded by the threshold; the queue snapshot is applied at drain withrename. - Files ignored by the repo's
.gitignoreare visible local-only — they appear in the mount, persist in the working tree across remounts and never enter a commit/push. - An encrypted repo is transparent — with
encryption_enabled, indexes are born from the decrypted names, reads open AEAD (wrong key/tampered blob =EIO), readlink presents the plaintext target and conflicted copies show the decrypted prefix. Ignore/local-only are disabled in this mode. - Chunked files are transparent — a path with a manifest exposes the real size and reads by range per chunk (sha256 verified once per session, mismatch =
EIO);.chunks/is invisible in the mount.
- Write a test in
tests/fs_hierarchy.rscallingfs.apply_<op>(...). - See RED (the function does not exist).
- Implement
apply_<op>insrc/fs.rsas a pure function over the indexes/buffers. - Implement the
Filesystemtrait wrapper that delegates and replies to the kernel. - Decide the effect on sync (
queue_*) and on the working tree. - Update the operations table above.
See sync-flow.md for the general guide and architecture.md for the end-to-end checklist.
| File | Responsibility |
|---|---|
src/fs.rs |
GitDriveFS, FileEntry, DirEntry, apply_*, write buffers + dirty, refresh_if_needed, Filesystem impl |
src/git.rs |
Blob reading and tree listing |
src/sync.rs |
Consumer of queue_* called by the FUSE mutations |
tests/fs_hierarchy.rs |
Tests for the apply_* functions, the hierarchical model and the buffer lifecycle |
Related docs: