This file guides coding agents (and human contributors) working in this repository.
- Drain correctness must NEVER depend on sync-queue coalescing — coalescing is only an economy on redundant content. The queue (
Vec<SyncOp>) preserves FUSE kernel arrival order, which is the causal order; any "optimization" that reorders or drops ops reintroduces the shuffled chained-rename and write-before-rename bugs fixed in Phase 0 (data integrity). checkout force(and any merge/FF) may only run behind both guards:repo_lockacquired ANDis_working_tree_cleantrue. A new checkout call site that skips the guards reintroduces silent loss of uncommitted local changes (incident: FF pull destroyed writes made in identity-less mode).- An inode write buffer must NEVER start empty for an existing file — always a copy of the current content via
ensure_buffer(POSIX semantics). Empty buffer + partial write zeroed the rest of the file on commit (append/truncate corruption, fixed in Phase 0).
unwrap_or(expr)ALWAYS evaluatesexpr(eager argument). With a side-effecting expression (e.g.,alloc_inode()), the effect leaks even on theSomepath — this was the inode leak in refresh. Useunwrap_or_else; the project's clippy does not catch this case by default.- FUSE
flushruns on EVERYclose(2), including fds opened read-only. Any flush logic that assumes "a write happened before" must check the dirty flag — without it, a simplecatre-enqueued stale content and reverted a remote merge. git2::Remote::pushreturnsOkat the transport level EVEN when the ref is rejected by the server (e.g., GitHub's GH001 for blobs > 100 MB) — the rejection only arrives through thepush_update_referencecallback. Incident symptom (2026-07-03): "push ok" logged with the remote stalled and reconcile never firing. A new push call site must collect callback rejections the waypush_to_remotedoes (fixfa43c2e), otherwise the false positive returns.
- Full suite:
cargo test - Lint (new warnings outside
vendor/are a regression):cargo clippy --all-targets - Phase 0 forbidden patterns:
grep -rn "op_priority" src/andgrep -n "unwrap_or(&self.alloc_inode" src/fs.rsmust return nothing - CI (
.github/workflows/ci.yml) only MIRRORS these commands on push/PR. Decoupling clause (project decision, 2026-07-05): no code insrc//tests/may know about or depend on the CI (grep -rnE 'GITHUB_' src/ tests/must return nothing); the only automation beyond verification will be the release automation, in a future slice.
cargo build # compile
cargo test # full suite (unit + integration)
cargo run -- -r <repo_path> -m <mount_path> # mount the Git repository as FUSE
cargo run -- install -r <repo> -m <mount> [--with-tray] # LaunchAgent (mount at login)
cargo run -- uninstall # stop the daemon and remove the agents
cargo run -- status # daemon state
cargo run -- tray # status icon in the menu bar
cargo run -- key generate [--out <path>] [--force] # at-rest encryption keyMount-mode flags: --config, --log-dir, --status-file. Config resolution without the flag:
./config.json (dev) > ~/.config/git-drive/config.json (canonical, with the encryption key
next to it) > legacy location in ~/Library/Application Support/git-drive/ (install migrates).
Status stays in ~/Library/Application Support/git-drive/, logs in ~/Library/Logs/git-drive/.
Requires macFUSE on macOS (brew install macfuse). The mount directory must exist before the command.
git-drive is a FUSE filesystem in Rust that exposes files of a Git repository as a mountable filesystem, with bidirectional sync via Git: local writes become automatic commits + pushes and remote changes arrive via periodic pull. It behaves like a "Dropbox-style" drive where the remote is a Git repository (including GitHub).
Main flow: CLI -> Config::load_from -> SyncManager::new_full_with_pull (write watcher + pull watcher) -> GitDriveFS::new -> fuser::mount2.
| File | Role |
|---|---|
src/main.rs |
Synchronous entry point with subcommand dispatch (status/install/uninstall/tray). Mount mode: explicit tokio runtime, prepare_mountpoint (recovers a zombie mount after a crash), fuser::spawn_mount2, SIGTERM/SIGINT/eject wait and ordered shutdown (unmount -> drain_now -> status/stop notification). |
src/status.rs |
Observable daemon state: serializable SyncStatus (unix timestamps, mount, log_file, stopped_at), StatusState with boot/drain/pull/stop events, atomic write and the status subcommand's format_status. |
src/paths.rs |
Path resolution: CWD-relative defaults (dev) and macOS defaults (launchd); status discovery order for reads. |
src/launchd.rs |
LaunchAgents: pure render_plist/render_tray_plist (template + XML escaping), install/uninstall via launchctl bootstrap/bootout, KeepAlive {SuccessfulExit=false}. |
src/notify.rs |
macOS notifications via osascript: pure formatting, Notifier off by default, RateLimiter (first + 1/h) for persistent events. |
src/tray.rs |
Menu bar icon (tray-icon + tao): status.json polling, 4 visual states mapped by evaluate (pure), menu with Finder/log tail/status/quit. |
src/lib.rs |
Re-exports modules to enable integration tests (tests/*.rs). |
src/fs.rs |
GitDriveFS implements fuser::Filesystem. Hierarchical model with FileEntry/DirEntry (kind Regular/Symlink and local-only flag), atomic inode allocator, apply_* functions for pure testable logic (includes apply_write/apply_truncate/apply_flush/apply_readlink/apply_symlink). Write buffers initialized from HEAD with a per-inode dirty flag — copy-on-write RAM (Arc shared with the queue) below large_file_threshold_mb, spool file in .git/git-drive-spool/ above; gc of clean buffers when commit_generation advances or on post-pull refresh. Ignored working-tree files appear as visible local-only entries (boot/refresh/gc). mtime derived from the last commit per path (derived_times); reads on cache miss use a persistent repo handle. setattr + in-memory xattrs (mode_overrides, times, xattrs). |
src/git.rs |
Wrapper over git2. Besides reads (list_files_at_commit returns ListedFile with FileKind Regular/Symlink via filemode 120000), exposes current_branch_name, list_dirs_at_commit, list_ignored_files (local-only candidates), commit_times_by_path (single mtime revwalk with a ceiling), commit_working_tree (the add_all with DEFAULT honors the mounted repo's .gitignore — specified and tested behavior), push_to_remote, fetch_remote, merge_ff_only, pull_and_merge (3-way + conflicted copy), is_working_tree_clean (pull guard; ignored files do not block) and the MergeOutcome enum (NoOp/FastForwarded/Merged/Conflicted). |
src/sync.rs |
SyncManager with the SyncOp enum (Write/Delete/Mkdir/Rmdir/Rename/Symlink) and a causal queue in arrival order (push_coalescing: Write coalesces in-place; rename/delete/mkdir/rmdir/symlink are barriers). SyncOp::Write carries WriteContent — Mem(Arc) shared with the write buffer or Spooled(PathBuf) applied by rename at drain. Debounce watcher applies operations + commit + push; the periodic pull watcher signals refresh via AtomicBool. The two cycles are serialized by repo_lock; pull requires a clean working tree (is_working_tree_clean). Filters ephemeral macOS paths (._*, .DS_Store, .Spotlight-V100, .Trashes, etc.) in enqueue. |
src/config.rs |
Config::load_from loads config.json with silent fallback to defaults. Fields: write_debounce_secs, pull_interval_secs, read_cache_mb, large_file_threshold_mb, chunk_threshold_mb, chunk_size_mb, commit_batch_mb, user_name, user_email, remote_token, log_max_mb, log_keep_files, log_level. |
src/chunk.rs |
Pure chunking core (Phase 2.6): versioned text manifest (drive-sync-chunked v1), .chunks/<2 hex>/<sha256> sharding, streaming slicing with dedup, offset->chunks mapping. |
src/crypt.rs |
At-rest encryption (Phase 2.7): convergent XChaCha20-Poly1305 (nonce derived from the plaintext — dedup and stable Git blob), HKDF subkeys, per-component names in lowercase base32 with literal fallback, 0600 key file. The repo stores everything encrypted; FUSE presents plaintext. |
FileEntry(src/fs.rs) —inode,path(full),parent,size,kind(Regular/Symlink),local_only.DirEntry(src/fs.rs) —inode,path,parent,children: HashSet<u64>.GitDriveFS(src/fs.rs) — bidirectional indices (files_by_inode,dirs_by_inode,inodes_by_path),next_inode: Arc<AtomicU64>, per-inode write buffers (WriteBufferRAM/spool), persistent read repo handle,derived_times,SyncManager.SyncOp(src/sync.rs) — enum with six variants covering all mutations;Vec<SyncOp>queue in arrival order.SyncManager(src/sync.rs) — two async watchers (debounce-commit-push and pull) serialized byrepo_lock: Arc<Mutex<()>>, optional identity and credentials,refresh_flag: Arc<AtomicBool>,commit_generation: Arc<AtomicU64>.Config(src/config.rs) — 7 fields with#[serde(default)].
config.json at the project root. If absent or invalid, defaults are applied silently (with a warning log).
| Field | Type | Default | Description |
|---|---|---|---|
write_debounce_secs |
u64 |
5 |
Seconds of idleness before draining the buffer, applying ops, committing and pushing. |
pull_interval_secs |
u64 |
60 |
Interval between git fetch + merge attempts against the origin remote. 0 disables the pull watcher. |
read_cache_mb |
u64 |
64 |
LRU read blob cache budget, in MiB. 0 disables it (every read goes to the repo, via the persistent handle). |
chunk_threshold_mb |
u64 |
95 |
Files above this are committed as manifest + chunks in .chunks/ (no blob above the threshold is staged — GitHub's 100 MB/file limit). 0 disables. |
chunk_size_mb |
u64 |
80 |
Maximum size of each chunk. |
commit_batch_mb |
u64 |
256 |
Maximum content bytes per drain batch: the causal queue is split into size-bounded batches, each its own commit, published one push per commit (bounded packs — a single giant pack is what GitHub aborts). A single file larger than the limit forms a batch of its own. 0 disables batching. |
encryption_enabled |
bool |
false |
At-rest encryption: content AND names encrypted in the repo (FUSE presents plaintext). Changing the value migrates the worktree on the next drain — prior history stays as it was. Enabled without a readable key ABORTS boot. |
encryption_key_file |
Option<String> |
None |
Master key file (64 hex, 0600). Absent = ~/.config/git-drive/drive.key (read fallback in the legacy location). Generate with git-drive key generate. LOSING THE KEY = LOSING THE DATA. |
large_file_threshold_mb |
u64 |
64 |
Threshold above which write buffers go to a disk spool file (.git/git-drive-spool/) instead of RAM. 0 disables the spool. |
user_name |
Option<String> |
None |
Identity used in commits. If absent, commits are skipped (the working tree keeps updating). |
user_email |
Option<String> |
None |
Same treatment as user_name. |
remote_token |
Option<String> |
None |
HTTPS token (e.g., GitHub PAT). If absent, uses the system ssh-agent/credential helper. Plain text — keep config.json in .gitignore. |
log_max_mb |
u64 |
50 |
Maximum size of a session log file before rotating (flexi_logger, Naming::Timestamps). 0 disables size-based rotation. |
log_keep_files |
usize |
10 |
How many rotated log files to keep; older ones are pruned. |
log_level |
String |
"info" |
Default log level (error/warn/info/debug/trace); RUST_LOG overrides at runtime. |
- FUSE write (
write) ->apply_write: the inode buffer starts as a copy of the HEAD blob (POSIX semantics for partial writes/appends) and the inode is marked dirty. flush->apply_flush: enqueuesSyncOp::Writein theSyncManageronly if dirty (closing a read-only fd is a no-op) and clears the mark. The content is a shared snapshot (Arc) or a disk-to-disk copy of the spool — never a full in-RAM copy.- The debounce watcher drains the queue after
write_debounce_secsof idleness and holdsrepo_lockfor the whole cycle. - Each
SyncOpis applied to the working tree in arrival order (fs::writeor spool rename,fs::rename,mkdir -p + .gitkeep, real symlink, etc.). After application, the chunking sweep converts any raw file abovechunk_threshold_mbinto manifest +.chunks/and the GC removes chunks no manifest references. - If an identity is present,
git::commit_working_treecreates 1 commit with messagegit-drive: N changes (p1, p2, p3[, +M more])and incrementscommit_generation. Paths matching the mounted repo's.gitignorestay out of the commit — in the mount they remain visible as local-only. - If
remote_nameis present,git::push_to_remotepublishes to the branch. Failure triggersreconcile_and_retry_push(fetch +pull_and_merge+ retry). The local commit is never reverted. - In parallel, the pull watcher every
pull_interval_secs— underrepo_lockand only with a clean working tree — runsgit::fetch_remote+git::pull_and_merge(ormerge_ff_onlywhen identity-less). On any outcome that touches the tree (FF/Merged/Conflicted), it setsrefresh_flag. GitDriveFS::refresh_if_needed(called inlookup/getattr/readdir) discards non-dirty buffers whencommit_generationadvanced or on post-pull refresh, and rebuilds indices preserving inodes of still-existing paths.- Shutdown (SIGTERM/SIGINT/eject): unmount BEFORE
drain_now— the kernel delivers flushes of open fds, the final drain commits/pushes everything, the status records the stop and the daemon exits 0 (launchd KeepAlive does not resurrect a clean stop; a crash relaunches and boot recovers the zombie mount).
cargo test # all tests (unit + integration)
cargo test --lib # unit tests only
cargo test --test fs_hierarchy # hierarchical model and apply_* tests
cargo test --test sync_flow # watcher -> disk -> commit -> push -> pull flow
cargo test --test e2e_integration # end-to-end scenariosThe suite covers ~253 tests: 110 unit (config, git — including symlink/ignore/mtime —, sync, BlobCache, status, paths, launchd, notify, tray::evaluate), 98 for hierarchy/apply_*, buffers (RAM and spool), cache, symlinks, local-only, derived mtime, chunked and encrypted reads, ephemeral renames, .gitkeep and .Trashes (fs), 39 end-to-end flow (drain, causal batches, pull watcher, dirty-tree guard, drain_now, status.json, symlink/ignored/spool through commit), 4 E2E, 1 smoke. End-to-end tests through the FUSE kernel are manual — the apply_* methods allow testing all the logic without mounting FUSE.
apply_read(src/fs.rs) consults the buffer (RAM or spool), then the LRU blob cache (read_cache_mb) and only then the repo — through a persistent handle opened once (invalidated on error). Files larger than the budget do not enter the cache, but do not pay one repo open per syscall.refresh_if_neededis called on everylookup/getattr/readdir. Cheap when the flag isfalse(atomic read); expensive whentrue(rebuilds entire indices).- The pull watcher skips an iteration if a flush batch is pending — avoids competing with local commits.
- Automatic
.gitkeeponmkdir. Git does not track empty directories; git-drive creates/removes.gitkeepto keep the illusion. - Plain-text credentials in
config.json..gitignorecovers the file by default. A keychain mechanism is future work. - 3-way merge with conflicted copy. Pull uses
pull_and_mergewhen an identity is configured: FF if possible, otherwise 3-way merge; on conflict, the local version goes to<path>.conflict-<host>-<ts>and the remote one becomes canonical (merge commit with 2 parents). Identity-less, it degrades tomerge_ff_only(stalls on divergence). Pull requires a clean working tree. With uncommitted changes, the iteration is skipped — in identity-less mode (commits skipped), the first local write blocks pull until a manual commit or identity configuration. Sync becomes effectively unidirectional in that mode. - Push failed -> automatic retry. If a push is rejected (typically non-FF),
reconcile_and_retry_pushdoes fetch +pull_and_merge+ push again. Persistent failure only logs; the local commit is never reverted. - Rename detection depends on Git's heuristic. We do not use
git mv; the working tree sees delete + create. - Symlinks supported; no FIFOs, sockets or real hardlinks (
linkis a copy). Symlink targets are opaque (no-follow, never validated): an absolute target created on one machine is broken on another, as in plain Git. Repos with LFS show the text pointer (no smudge/clean filters). 6b. Files abovechunk_threshold_mbbecome manifest +.chunks/(Phase 2.6): transparent in the mount (real size, reads reassembled with sha256 verification); a plain Git clone sees the text manifest and the chunks (manual recovery: concatenate the chunks in manifest order)..chunksat the root is reserved. No incremental re-chunking (CDC): editing a large file rewrites the chunks touched by position. - Permission metadata in memory. Exposed
uid/gidare those of the process that mounted FUSE (vialibc::getuid()/getgid()).chmodis accepted and stored inmode_overrides(per-inode) but does not persist across mounts — Git only tracks0o644/0o755.chownis silently accepted and ignored. - mtime derived from history; session touches in memory. An untouched file exposes the timestamp of the last commit that touched the path (single revwalk at boot/refresh, 4096-commit ceiling, the remainder falls back to HEAD's timestamp);
touchinflush/setattrtakes precedence and does not persist across mounts.atime/ctime/crtimemirror the available mtime. Local-only entries and directories useboot_time. Stable mtime acrossgetattrs is what unblocks saving in macOS apps that detect "external modification" via stat. - xattrs in memory.
setxattr/getxattr/listxattr/removexattrstore inHashMap<u64, HashMap<OsString, Vec<u8>>>. Lost on remount — macOS Versions only works while the mount is alive. Git does not store arbitrary xattrs. - Detached HEAD aborts boot. git-drive requires a named branch.
- The blob cache does not cover files larger than the budget (
read_cache_mb) — for them every read goes to the repo, but via the persistent handle (one open per mount). Write buffers abovelarge_file_threshold_mblive in an on-disk spool. - Refresh only triggers on
lookup/getattr/readdir. If the kernel cachesgetattr(1s TTL), remote changes can take up to that TTL to appear. - Ambiguous implicit swap (more than one file at the origin) returns
EXDEVand the app degrades to a copy. - The Finder Trash lives in memory. Items moved to the trash disappear from the repo and are recoverable only while the mount lives — a remount "empties" the Trash.
.gitkeepis invisible in the mount (an internal artifact of the empty-dir illusion). - Notifications attributed to "Script Editor" (osascript, no app bundle). Migration to UserNotifications when there is a bundle (roadmap).
- Optional at-rest encryption (Phase 2.7). With
encryption_enabled, content and names live encrypted in the repo (working tree included) and the mount presents everything in plaintext. Convergent by design (dedup and stable tree; equality oracle accepted). What it does NOT protect: pre-migration history, approximate sizes, commit count/cadence, the local machine. Ignore/local-only are disabled in encrypted mode; plaintext component names limited to ~119 bytes; lost key = unrecoverable data (no backdoor). Multi-machine = copy the key file. - Ignored files are visible local-only entries. Paths matching the mounted repo's
.gitignoreappear in the drive and persist in the working tree across remounts, but never enter a commit/push — they do not sync between machines. The exclusion mechanism is.gitignoreitself (or.git/info/excludefor local policy); there is no allowlist. An ignore rule removed remotely makes the file get committed on the next drain (auto-resolve).
All user-facing strings (CLI help, logs, notifications) and all code comments are in English. Portuguese documentation lives in docs/pt-br/.
- docs/usage.md — step-by-step usage guide (prerequisites, credentials,
config.json, mount, day-to-day, troubleshooting) - docs/architecture.md — overview, layers and data structures
- docs/sync-flow.md — debounce and pull watchers, commit, push, refresh
- docs/fuse-filesystem.md — hierarchical inode mapping and FUSE operations
- Portuguese versions: docs/pt-br/