In short: the mount has two top-level directories. commits/ holds one directory per
visible commit, named by its full hex commit ID. workspaces/ holds one directory per
workspace, showing that workspace's working-copy commit. Inside either, you see the commit's tree
as files, directories and symlinks. Every request re-resolves its full path from scratch against one
ReadonlyRepo, loaded once at mount time. The mount never sees later operations, so it
is a frozen snapshot.
All of this is decided by one type,
AllCommitsPathMapper, the only
PathMapper in the crate. A
PathMapper has a single method, get_entry(path) → Box<dyn VirtualFile>,
and four VirtualFile implementors sit behind it: StaticDirectory,
CommitsDirectory, WorkspacesDirectory and CommitTreeFile. The
traits themselves are on the virtual file layer page.
1. The tree
This is the complete namespace at the pinned commit. Nothing else exists: any other name at the top
level is ENOENT, and there are no hidden files, no .jj directory and no
metadata files.
The contents of a commit directory (here dir/, file1.txt and
symlink, the tree the upstream test helper builds) are whatever that commit's tree holds.
The commits as directory trees
page covers how. The all-zero entry is jj's root commit, which has an empty tree. It is listed because
all() includes it, and it resolves because the Git backend answers the all-zero ID with
a synthetic root commit
(source).
2. Path resolution
Where paths come from
The kernel never sends a path, only an inode number and sometimes a name. Before anything reaches
the path mapper, reply_async! in
JjFuse asks InodeMap::get_path to
rebuild the path by following parent links up to inode 1. The result is relative and has
no leading slash: the root is the empty path "", and a file inside a commit is
commits/<id>/dir/file2.txt. For lookup, JjFuse pushes the
requested name onto the parent's path first. Every
VirtualFilesystem method of
PathMappedVfs then starts the same way:
self.path_mapper.get_entry(path).await?, and then a single trait call on the result. Nothing is
cached between requests: every getattr, readdir, read and
readlink resolves the full path again.
get_entry, step by step
AllCommitsPathMapper::get_entry walks the path with
path.iter() and looks at no more than two components itself:
-
No components (the root): return a fresh
StaticDirectorylistingcommitsandworkspaces, both as directories (source). -
First component: convert it to UTF-8, or fail with
InvalidPath. Then match on it."commits"and"workspaces"go on to step 3, and anything else isNotFound(source, source). -
Under
commits(source): with no second component, return aCommitsDirectory. Otherwise parse the second component withCommitId::try_from_hex, where a string that isn't valid hex givesNotFound. Then build aCommitTreeFilefrom the commit ID and all remaining components (segments.collect()) as the path inside the commit. -
Under
workspaces(source): with no second component, return aWorkspacesDirectory. Otherwise look the name up in the repo view'swc_commit_ids(), where an unknown workspace givesNotFound. Then build aCommitTreeFilefor that workspace's working-copy commit and the remaining components, exactly as in step 3.
CommitTreeFile::new immediately loads the commit with
Store::get_commit_async, so a
bad commit ID fails during resolution, before any file operation. It does not look at the path inside the
commit. A missing file inside a real commit only fails later, when the operation asks the tree
for that path.
Commit IDs: full only, and how they fail
The directory name must be the commit's full hex ID: 40 digits in a Git-backed
repository, which is any repository made with jj git init or jj git clone.
There is no prefix resolution, no change IDs, and no revset or bookmark names. The failure modes
differ in ways a user can see. Every row below was checked by running the pinned code against a
fresh Git-backed repository:
Second component under commits/ | What happens | JjError | errno |
|---|---|---|---|
| Not valid hex, or an odd number of digits | try_from_hex returns None | NotFound | ENOENT |
| Valid hex, but not 40 digits (a prefix) | The Git backend rejects the length with BackendError::InvalidHashLength (source). The local backend the upstream tests use reports ObjectNotFound instead. | JjLibBackendError | EIO |
| 40 hex digits naming no object | The backend's object lookup fails with BackendError::ObjectNotFound | JjLibBackendError | EIO |
| The ID of any commit object in the store | Resolves. The store is not checked against the view, so this includes commits that commits/ doesn't list | — | — |
So ls <mount>/commits/abcd reports "Input/output error", not "No such file or
directory": every error other than NotFound, NotADirectory,
NotAFile and NotASymlink becomes EIO in
From<JjError> for Errno.
All the errors resolution can return
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
InvalidPath | JjError → EIO | A component isn't valid UTF-8, or the path inside a commit isn't a valid repo path. | jj_error.rs |
NotFound | JjError → ENOENT | Unknown top-level name, non-hex commit directory, unknown workspace, or (later) a missing path inside a commit. Also an inode the InodeMap doesn't know. | jj_error.rs |
JjLibBackendError | JjError → EIO | Any jj-lib backend failure, including a commit ID of the wrong length or one that doesn't exist. | jj_error.rs |
3. The directory types
All three are cheap wrappers built fresh by get_entry on every request. None of them
implements read or read_link, so those fall through to the
VirtualFile defaults and return
NotAFile (EISDIR) and NotASymlink (EINVAL).
| Item | list | attributes | file_type |
|---|---|---|---|
StaticDirectory |
The Vec<DirectoryEntry> it was built with, cloned into a stream, in that order. |
size 0, Directory, created and modified at UNIX_EPOCH (marked TODO) |
Directory |
CommitsDirectory |
Every commit in ResolvedRevsetExpression::all() evaluated on the repo, as a directory named commit_id.hex(). Entries whose ID fails to load are silently skipped. |
size 0, Directory, UNIX_EPOCH |
Directory |
WorkspacesDirectory |
The keys of view.wc_commit_ids(), a BTreeMap, so sorted by name, each as a directory. |
size 0, Directory, UNIX_EPOCH |
Directory |
How CommitsDirectory lists commits
CommitsDirectory::list evaluates jj-lib's
all(), which resolves to the
ancestors of the visible heads (source),
so that is every visible commit, including the root. In jj terms, it is the same set as
jj log -r 'all()'. The source explains the next step: "Revset does not implement
Send", so the stream can't cross threads. Instead of streaming, it calls
futures::executor::block_on inside the async function to collect every ID into a
Vec, and then streams that. This blocks a tokio worker thread for the whole
evaluation, and the source flags both the blocking and the missing pagination as temporary.
The upstream test test_repo_commits pins the
count: a fresh repo with one test commit lists 3 entries ("root commit + initial workspace commit +
our test commit"), all directories.
test_repo_workspaces pins the single
default workspace.
4. Repo snapshot semantics
main loads the workspace, calls
RepoLoader::load_at_head exactly
once (merging divergent operation heads if there are any), and moves the resulting
Arc<ReadonlyRepo> into AllCommitsPathMapper::new. That
Arc is never replaced. Every directory type clones it, and no code path reloads the
repo. So for the whole life of the mount:
-
commits/lists the visible commits as of mount time. Commits created, rewritten or abandoned later don't change the listing. Abandoned ones stay listed, and new ones never appear. -
workspaces/is frozen too. Workspaces added later don't appear, andworkspaces/<name>/keeps showing the working-copy commit as of mount time, even afterjj neworjj editmoves@. It is not a live view of the working copy. In particular, it never shows uncommitted edits on disk, which jj only records when it snapshots. -
Lookup by commit ID bypasses the view.
commits/<full id>/resolves any commit the store can read, so a hidden commit's tree can still be reached by ID even thoughls commits/doesn't show it.
The kernel adds one more layer of staleness: JjFuse replies with a 1-second
TTL on every entry and attribute (source), so the
kernel may answer repeated lookups from its cache for up to a second. Since the snapshot never changes,
this costs nothing in correctness.
5. Worked example
Take a repository with three commits besides the root: A, which adds
file1.txt ("hello content 1"); B, a child of A, the
working-copy commit of the default workspace; and C, another child of
A, the working-copy commit of a second workspace, docs. Mounted with
jjfsd <mountpoint> <workspace root>, these are exactly the paths that exist
(<A> standing for A's 40-digit ID, and so on):
<mountpoint>/
├── commits/
│ ├── 0000000000000000000000000000000000000000/ (empty: the root commit's tree)
│ ├── <A>/file1.txt
│ ├── <B>/file1.txt
│ └── <C>/file1.txt
└── workspaces/
├── default/file1.txt (= commits/<B>)
└── docs/file1.txt (= commits/<C>)
The commit directories are listed newest first, with the root last, which is the order the revset
stream yields them. The workspaces are sorted by name. If B and C changed nothing, all three
file1.txts have the same content, but they are separate paths with separate inodes, and
each is read from the store independently.
What cat <mountpoint>/commits/<A>/file1.txt does
Starting from a cold kernel cache, the kernel resolves the path one component at a time with
lookup, opens the file, and reads until it gets a short read. Each FUSE call arrives on a
fuser thread, and JjFuse immediately spawns the work onto the tokio runtime
and returns. The reply is sent from inside that task.
Three details show up here that a reader might not expect:
-
Getting the file's size reads the whole file.
CommitTreeFile::attributescallsfile_type(onepath_value), then for a file callsread()and copies the stream into a sink to count its bytes, with a TODO noting jj-lib would need changes to avoid this.read()does its ownpath_value, which is why the lookup row saysread_fileafter it. -
Each
readstarts from the beginning.PathMappedVfs::readopens a new reader, copiesoffsetbytes into a sink ("TODO: handle proper seek()"), then takessizebytes. Reading a large file in chunks costs time proportional to the square of its length. -
Inodes are allocated before the path is known to exist.
lookupcallsget_inobeforeget_attributes, so a lookup that ends inENOENTstill leaves a new inode in the map, andforgetis left tofuser's no-op default (fuser source). Inodes are never freed.
6. Limitations the code makes visible
| Limitation | Where it shows | Source |
|---|---|---|
Read-only mount. There are no write operations at all, and the mount is created with MountOption::RO and file system name jjfs. | Writes fail. JjFuse implements only init, lookup, getattr, read, readdir and readlink. | main.rs, fuse.rs |
| Frozen snapshot: the repo is loaded once at mount. | New commits, moved working copies and new workspaces never appear. | main.rs |
Full commit IDs only. Prefixes fail with EIO, not ENOENT. | commits/<prefix> | path_mapper_all_commits.rs |
| Conflicted paths can't be read, typed or sized. | read returns NotADirectory (ENOTDIR) on a conflict. file_type, and so getattr, returns an InvalidData I/O error ("Conflicted path"), which is EIO. In a listing, conflicted entries show as regular files. | read, file_type, list |
| Submodules (and any other value kind) are shown as files but can't be read. | Listing says file, and read returns NotAFile (EISDIR). | read, list |
Executable bit not shown. Every non-directory is 0644, every directory 0755, owned by uid/gid 1000. | ls -l | fuse.rs |
No timestamps. Created and modified are UNIX_EPOCH everywhere, and access time is "now". | ls -l, stat | fuse.rs |
No pagination. commits/ collects every visible commit (blocking a worker thread), and commit directories collect their whole listing before replying. | Large repositories | commits_directory.rs, commit_tree_file.rs |
| Whole-file reads for size and for every read offset. | Large files | attributes, read |
| Inodes are never forgotten. | Memory grows with every distinct path ever looked up, including failed ones. | inode_map.rs |
One upstream test is named for a case it doesn't exercise.
test_all_commit_trees_mapper_invalid_commit_id
resolves the path invalid_commit_hex at the top level, so it hits the "unknown
top-level name" arm, not commit-ID parsing. Nothing in the suite covers a bad ID under
commits/.