jj / The working copy

The working copy

In jj, the files you edit are a commit: @. The working copy layer turns the directory on disk into a tree (snapshot) and turns a tree back into files (check out). It remembers enough about each file to avoid re-reading the ones that haven't changed.

On this page
  1. 1. The interface
  2. SnapshotOptions and stats
  3. 2. The stored state
  4. 3. Snapshotting
  5. 4. Checking out
  6. 5. Stale working copies
  7. 6. Settings and file monitors

In short: .jj/working_copy/ holds two small files. checkout records which operation and workspace the files on disk belong to. tree_state records the checked-out tree plus, for every tracked file, its type, mtime and size. Snapshotting walks the directory in parallel, re-reads only files whose recorded state no longer matches, and writes a new tree. The repo layer then rewrites @ to point at it.

1. The interface

Working copies are pluggable. The CLI picks a WorkingCopyFactory by name; local is the default.

TraitMethods
WorkingCopy (unlocked, read-only) workspace_name, operation_id, tree, sparse_patterns, and start_mutation(), which takes the lock and returns a LockedWorkingCopy.
LockedWorkingCopy old_operation_id and old_tree (as of locking); snapshot(options); check_out(commit); reset(commit) and recover(commit), which repoint the state without touching files; set_sparse_patterns; rename_workspace; and finish(operation_id), which writes the state with the new operation and releases the lock.

SnapshotOptions and stats

FieldMeaningFrom config
base_ignoresIgnore patterns to start from (user and repo excludes). Each directory's .gitignore is layered on during the walk.Git exclude files
start_tracking_matcherNew, untracked files that match start being tracked.snapshot.auto-track, default all()
force_tracking_matcherTrack these even if they're ignored or too large (used by jj file track).
max_new_file_sizeLarger new files stay untracked. Already-tracked files are always snapshotted.snapshot.max-new-file-size, default 1MiB
progressCallback for the progress bar.

A snapshot returns SnapshotStats: untracked paths, each with an UntrackedReason (FileTooLarge { size, max_size } or FileNotAutoTracked), plus paths skipped because their names aren't valid UTF-8. A checkout returns CheckoutStats: updated, added, removed, and skipped files, where skipped means an untracked file was already in the way (source, defaults).

2. The stored state

FileContentsRust
.jj/working_copy/checkoutThe operation the files were last updated at, and this workspace's nameCheckoutState
.jj/working_copy/tree_stateThe checked-out tree (as Merge<TreeId> plus conflict labels), one FileState per tracked path, sorted, the sparse patterns, and the last Watchman clockTreeState
.jj/working_copy/working_copy.lockHeld while a LockedWorkingCopy existsstart_mutation

Each FileState holds a file_type (Normal { exec_bit }, Symlink or GitSubmodule), mtime, size, and materialized_conflict_data, which is set when the file on disk holds conflict markers and records their length. The exact encoding is on Every schema. start_mutation re-reads both files after taking the lock, so a locked working copy always sees the latest state.

3. Snapshotting

How a snapshot turns files into a tree The matcher is the intersection of the sparse patterns with the union of the fsmonitor's changed files and the force-tracking matcher. A rayon scope walks directories in parallel. For each file it compares the on-disk state with the stored FileState: if type, mtime and size match and the mtime is older than the tree_state file's own mtime, the file is skipped; otherwise it's written to the store. Results flow through channels: new tree values into a MergedTreeBuilder, changed FileStates and deletions into the file-state map, and untracked paths into the stats. The builder writes the new tree. matcher sparse ∩ (fsmonitor ∪ force-track) parallel directory walk rayon scope · .gitignore per dir per file clean? skip : write to store MergedTreeBuilder → new tree file_states.merge_in changed + deleted SnapshotStats untracked paths results travel over mpsc channels

TreeState::snapshot:

  1. Builds the matcher: the sparse patterns, intersected with the union of the fsmonitor's changed paths and the force-tracking matcher. Without an fsmonitor, the fsmonitor part matches everything. If the matcher can't match anything, the snapshot returns immediately.
  2. Walks the directory as a queue of per-directory jobs in a rayon scope, layering each directory's .gitignore. Paths that were tracked but are no longer on disk are reported as deleted (emit_deleted_files).
  3. For each present file, get_updated_tree_value decides whether it's clean. It's clean when it was tracked, its type, mtime and size match the stored FileState, and its mtime is older than the tree_state file's own mtime. That last check avoids a race: a file modified in the same instant the state was saved can't be trusted. Clean files are skipped entirely.
  4. Otherwise the file is written to the store, keeping the existing copy_id and, where the filesystem can't express it, the executable bit. If the path was conflicted in the tree, the contents are parsed for conflict markers with conflicts::update_from_content, using the recorded marker length or the minimum of 7. A fully resolved file becomes a normal file; partly edited markers update the individual conflict sides (write_path_to_store).
  5. Results come back over channels and are folded in: new tree values go into a MergedTreeBuilder seeded with the old tree, deleted paths are removed, changed file states are merged into the sorted map, and the builder writes the new tree.
  6. The Watchman clock is saved, but only if no untracked paths were found, so untracked files get rescanned next time.

The repo layer then compares the new tree with @'s tree and, if they differ, records a snapshot working copy operation. See How a command runs.

4. Checking out

check_out(commit) diffs the old tree against the new one within the sparse patterns and applies each change on disk (TreeState::update). Files, symlinks and executable bits are written directly. A conflicted path is materialized: its sides are rendered into one file with conflict markers in the configured ui.conflict-marker-style, and the marker length is recorded in its FileState so the next snapshot can parse it back (write_conflict). New files are created with create_new, so an untracked file already at that path is never overwritten. It's counted as skipped instead. Paths that resolve into .git or .jj are rejected.

After checkout, the state is saved with the new tree and fresh file states, and finish(op_id) writes the operation into checkout.

5. Stale working copies

Each workspace's working copy records the operation it was updated at. The repo's view records the commit it should have (wc_commit_ids). WorkingCopyFreshness::check_stale compares the two using the closest common ancestor of the working copy's operation and the repo's:

Common ancestor is…Result
(operations equal)Fresh
the repo's operationUpdated: another process moved the working copy forward. Reload the repo there.
the working copy's operationFresh if the checked-out tree already equals @'s tree, otherwise WorkingCopyStale.
neitherSiblingOperation

A stale working copy is repaired by jj workspace update-stale, or automatically with snapshot.auto-update-stale. It checks out the commit the repo expects.

6. Settings and file monitors

SettingEffect
ui.conflict-marker-styleHow conflicts are materialized on checkout and parsed on snapshot.
working-copy.eol-conversionOptional CRLF ↔ LF conversion on snapshot and checkout.
working-copy.exec-bit-changerespect, ignore or auto (default): whether executable-bit changes on disk count. auto checks whether the filesystem supports executable bits (source).
fsmonitor.backendnone or watchman. With Watchman, only paths reported changed since the stored clock are examined.

TreeStateSettings · FsmonitorSettings

Sparse patterns are currently plain path prefixes. Paths outside them are neither snapshotted nor checked out. jj sparse set changes them through LockedWorkingCopy::set_sparse_patterns, which checks out or removes the affected files.