jj / Trees, files and copies

Trees, files and copies

How jj stores the contents of a commit: one sorted Tree object per directory, values that are files, symlinks, subtrees or submodules, and a MergedTree that lets a commit's root be a conflict between several trees. Plus how trees are diffed and how copies are tracked.

On this page
  1. 1. From commit to file contents
  2. 2. Tree: one directory
  3. 3. TreeValue
  4. 4. MergedTree and MergedTreeValue
  5. 5. Tree diffs
  6. 6. MergedTreeBuilder
  7. 7. Copies
  8. Detected copies: CopyRecord
  9. Tracked copies: CopyHistory

In short: a commit doesn't point at one tree but at a Merge<TreeId>. Usually that merge has one term, a plain snapshot. After a conflicted rebase or merge it has 2n+1 terms, and every path is looked up in all of them at once. A path's value is then a MergedTreeValue = Merge<Option<TreeValue>>, which is resolved when all terms agree after trivial merging. The trees themselves are immutable, content-addressed objects in the backend, so two commits with the same contents share their tree IDs.

1. From commit to file contents

How a commit reaches file contents A commit's root_tree field is a Merge of TreeIds. MergedTree wraps that merge together with the Store and conflict labels. Each TreeId names a backend Tree object, a sorted list of entries pairing a path component with a TreeValue. A TreeValue is a File with a FileId, an executable bit and a CopyId, a Symlink with a SymlinkId, a Tree with a TreeId for a subdirectory, or a GitSubmodule with a CommitId. FileIds and SymlinkIds are read through the Backend; CopyIds name CopyHistory objects. Commit.root_tree Merge<TreeId> MergedTree store, tree_ids, labels backend::Tree sorted (name, TreeValue) TreeValue File id, executable, copy_id Symlink SymlinkId Tree TreeId (subdirectory) GitSubmodule CommitId Backend::read_file / read_symlink CopyId → CopyHistory

2. Tree: one directory

There are two Tree types. The stored one, backend::Tree, is just entries: Vec<(RepoPathComponentBuf, TreeValue)>. The entries "must be sorted (by RepoPathComponentBuf's ordering) and must not contain duplicate names", which from_sorted_entries checks with a debug_assert!. Lookup by name is a binary search (entry, value). The in-memory tree::Tree wraps it with the Store, the directory path it lives at, and its TreeId, and adds path_value, sub_tree and sub_tree_recursive for walking down (source).

A subdirectory is just an entry whose value is TreeValue::Tree(id). There is no separate directory object, so a tree only stores its direct children, and an empty directory can't be represented. Every backend must give all empty trees the same ID regardless of path (Backend::empty_tree_id).

3. TreeValue

VariantFieldsMeaning
Fileid: FileId, executable: bool, copy_id: CopyIdA regular file. The content is read with Backend::read_file. The executable bit is part of the value, so changing only chmod +x is a change. copy_id ties the file to its copy history (see copies). Today's backends fill it with the empty placeholder (CopyId::placeholder(), marked "TODO: Delete this").
SymlinkSymlinkIdA symbolic link. The target is a String, not a repo path, because it can point outside the repository.
TreeTreeIdA subdirectory.
GitSubmoduleCommitIdA Git submodule, pinned at a commit of the other repository.

TreeValue · CopyId::placeholder

There is no conflict variant. Conflicts aren't stored inside trees any more: they live one level up, as several whole trees in the commit's Merge<TreeId>.

4. MergedTree and MergedTreeValue

MergedTree is "a lazily merged view of a set of trees": store, tree_ids: Merge<TreeId> and labels: ConflictLabels (one label per side, used in conflict markers). Nothing is merged up front. Each operation walks all the terms together and merges only the entries it touches.

MethodWhat it does
resolved, newWrap one tree ID, or a merge of tree IDs plus labels (the number of labels must match the number of sides).
treesLoad every term's root Tree.
path_valueThe MergedTreeValue at a path: walk to the parent directory in all terms (missing directories count as empty), then look the name up in each. The result can be resolved even when the tree as a whole is conflicted.
has_conflict, conflictsWhether there is more than one term, and a stream of the paths that are actually conflicted (recursing only where every side is a tree).
resolveRun the tree merge (tree_merge::merge_trees), then simplify the remaining conflict and its labels.
merge, merge_no_resolveCombine several merged trees into one by flattening their term lists, e.g. for a rebase (new parent + old commit − old parent).
entries, entries_matchingRecursive walk in path order, reading subtrees concurrently and merging them on the fly. A subtree that conflicts with a non-tree is reported once and not entered.

A path's value is MergedTreeValue = Merge<Option<TreeValue>>: one optional value per term, where None means "absent in this term". Helpers on MergedTreeValueExt classify it: is_tree (present and every term is a tree or absent), is_file_like (present and not a tree), and to_file_merge, to_executable_merge and to_copy_id_merge, which pull one field out of each term when every term is a file or absent. When the terms of a Merge<Tree> are looked up by name, TreeMergeExt::value first tries resolve_trivial, so a path that only one side changed comes back resolved.

Looking a path up in a three-term merged tree A conflicted commit has root tree terms side 1, base and side 2. For path a.txt, side 1 has file F1, the base has F0 and side 2 has F0, so the value resolves trivially to F1. For path b.txt, side 1 has G1, the base G0 and side 2 G2, so the value stays a conflict of three terms. For path new.txt, only side 2 has it, so it resolves to that file. side 1 (+) base (−) side 2 (+) path_value a.txt b.txt new.txt F1 F0 F0 resolved: F1 G1 G0 G2 conflict: G1 − G0 + G2 absent absent N resolved: N Trivial resolution: if a term equals the base it cancels, so only the side that changed remains.

5. Tree diffs

Comparing two merged trees yields a TreeDiffStream of TreeDiffEntry { path, values: BackendResult<Diff<MergedTreeValue>> }, a stream rather than an iterator "so high-latency backends (e.g. cloud-based ones) can fetch trees asynchronously". Every diff goes through one engine, TreeDiffStreamImpl, created with the store's concurrency as its limit (diff_internal):

  • TreeDiffVisitor handles one directory at a time. It merge-joins the entries of the "before" and "after" trees by name, keeps only changed entries the matcher accepts, and schedules a read of both sides for any entry where either side is a tree, missing sides counting as empty trees.
  • The walk itself is the generic OrderedTreeWalk in tree_walk.rs. Its TreeWalkQueue runs at most that many reads at once, starting the smallest paths first (PrioritizedFutures), and holds finished entries in runs keyed by path. An entry is emitted only once no smaller path still has a read pending, so the output is in path order: a directory before its contents, and a directory's entries before later siblings. It stops starting reads while 10,000 entries are waiting.
Entry pointOutput
diffOnly non-tree changes. Tree values are replaced with absent, and entries absent on both sides are dropped (stream_without_trees).
diff_with_treesAlso the directories that changed.
diff_for_file_systemReordered for checkout: files under a removed directory come before a file that replaces the directory at the same path.
diff_with_copiesJoins in copy records to report copies and renames (below).
diff_with_copy_historyThe same idea driven by CopyHistory objects instead of records.

6. MergedTreeBuilder

New trees are written with MergedTreeBuilder: start from a base MergedTree, record overrides, then write_tree(). There are two setters (source): set_or_remove(path, Option<TreeValue>) takes a resolved value (None deletes), and set_or_remove_merge(path, MergedTreeValue) takes a Merge that must be resolved or have exactly as many sides as the base tree, else it returns ConflictArityMismatchError. To combine paths from a differently conflicted tree, the source says to merge the trees instead. With no overrides it returns the base tree unchanged. Otherwise it first tries to resolve each conflicted override on its own (resolve_file_values, several at once), then puts the overrides in a path tree and rewrites every term of the base in one pass (source). The pass is the shared tree rewriter, rewrite_trees: it reads each tree that has an override at or below it, asks a visitor for the new entries, and writes the new tree once its changed subtrees are written, with at most store.concurrency() reads and writes in flight (source). A rewritten tree whose entries match the tree it was read from isn't written again; the original's id is reused (source). Each tree is rewritten once per path, id and context. The builder's context is the term index only under a conflicted override, so a subtree the terms share is read and written once (source). A non-tree override at a path wins over overrides below it. A file in the base with overrides below it becomes a directory, unless those overrides only remove paths. Subtrees that end up empty are dropped (source). If the written trees are trivially the same the result is one resolved tree; otherwise the root ids are simplified together with the base's conflict labels. The whole tree is no longer re-merged, so the base is expected to have its conflicts resolved already (source). The working-copy snapshot and most rewriting commands produce their trees this way.

7. Copies

Copy support has two halves, at different stages. See the design doc, docs/design/copy-tracking.md.

Detected copies: CopyRecord

CopyRecord describes one copy event: target path, target_commit, source path, source_file (a FileId) and source_commit. Backends produce them on demand with Backend::get_copy_records(paths, root, head), a stream in reverse-topological order. The Git backend computes them by running gix's rename and copy detection between the two commits' trees, skipping symlinks (source). The simple backend returns an empty stream.

CopyRecords indexes a batch by source and by target. A path that appears as the source or target of two different records is marked conflicting and ignored. Duplicates of the same (source, target) pair, which a merge commit reports once per parent, are skipped. copies_tree_diff_stream then rewrites a plain diff: an entry whose path is a copy target gets its source attached and is classified by resolve_copy_source as a Rename if the source is gone (or became a directory) in the target tree, and as a Copy otherwise. Those lookups run asynchronously, up to the store's concurrency limit at a time, with output kept in diff order. The separate "deleted" entry for a renamed source is dropped. This is what jj diff uses to show renames.

Tracked copies: CopyHistory

The other half is persistent identity. CopyHistory holds a file's current_path, its parents: Vec<CopyId> (none for a new file, one for a copy or rename, several for a merge of files) and a salt to give a logically new file a fresh ID. A file's TreeValue::File.copy_id names its history, which is "unchanged when a file is modified". RelatedCopy pairs an ID with its history, and Backend::get_related_copies returns the whole family. MergedTree::copy_value finds a file by copy ID.

At this pin neither built-in backend implements it. The Git backend answers read_copy, write_copy and get_related_copies with Unsupported: "The Git backend doesn't support tracked copies yet" (source), and the simple backend with "The simple backend doesn't support copies". So in a normal repository renames come only from detection.