jj / How jj file edit works

How jj file edit works

jj file edit opens one file from any mutable revision in your text editor, then saves the result back into that revision and rebases its descendants. Your working copy is never used; the file is checked out into a throwaway directory.

On this page
  1. 1. Command-line surface
  2. 2. Configuration
  3. 3. End-to-end flow
  4. 4. Core architecture touchpoints
  5. 5. Transaction and operation
  6. 6. Algorithm and invariants
  7. What can be edited
  8. A one-file working copy
  9. Saving back
  10. Conflicted files
  11. Descendants
  12. 7. Errors and edge cases
  13. 8. Worked example

In short: jj resolves -r (default @), refuses it if it is immutable, and checks the path is a file, absent, or a conflict of files. It builds a temporary working copy whose sparse patterns contain only that path, checks the commit's tree out into it (conflicts become markers), and runs the editor on the file. It then snapshots the temporary copy. If the tree is unchanged it prints Nothing changed.; otherwise it rewrites the commit, rebases or reparents descendants, and records edit file <path> in commit <id>.

The command is cmd_file_edit. Its help text: "Edit the contents of a file in a revision. The file is opened with the contents from the given revision. After the editor exits, the file is saved back to the revision and descendants are rebased on top of the updated commit. If the file does not yet exist in the revision, a new file will be created. If the file is conflicted, the conflict markers are materialized in the editor. Editing the conflict markers can resolve the conflict." (source). It is the Edit variant of FileCommand (source, dispatch).

1. Command-line surface

ArgumentTypeDefaultMeaning
FILE (positional)pathrequiredThe one file to edit. It is a plain path, not a fileset. Completion offers every file in the revision (source).
-r, --revision REVSETrevset@The revision to edit the file in. It must be exactly one commit. Completion offers mutable revisions (source).
--restore-descendantsboolfalse"Preserve the content (not the diff) when rebasing descendants" (source).

FileEditArgs. The global flags are on the CLI page.

2. Configuration

KeyDefaultEffect
ui.editornano (Notepad on Windows)The editor that is run (source, source, unix.toml, windows.toml). The documented precedence is $JJ_EDITOR > ui.editor > $VISUAL > $EDITOR (docs). If the command contains $path or $line they are substituted; otherwise the path is appended (source).
ui.conflict-marker-stylediffHow a conflicted file is written out for editing, and how markers are parsed back (source, misc.toml).
revset-aliases."immutable_heads()"built-inDecides whether the commit may be rewritten (source).

The temporary copy ignores your working-copy settings: it uses no line-ending conversion, no fsmonitor, and automatic executable-bit detection (source).

3. End-to-end flow

jj file edit: call flow cmd_file_edit loads the workspace through the shared lifecycle, resolves -r (default @), checks it is rewritable, parses the path and reads its value from the commit's tree, rejecting directories, symlinks, submodules and non-file conflicts. It creates a temporary directory with a wc and a state subdirectory, initializes a TreeState there whose sparse patterns are just the path, and checks the commit's tree out. It runs the configured editor on the file; if the editor fails, the directory is kept and the command errors. It snapshots the temporary copy. If the tree IDs are unchanged it prints "Nothing changed." and returns with no transaction. Otherwise it starts a transaction, rewrites the commit with the new tree, calls rebase_or_reparent_descendants, and tx.finish records "edit file" with the path and commit id. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) resolve_single_rev(-r); check_rewritable; parse_file_path; tree.path_value(path) only a file, nothing, or a conflict of files may be edited temp dir jj-file-edit-…/{wc,state}; TreeState::init; set_sparse_patterns([path]); check_out(tree) only the one file is written; a conflict is written as markers text_editor().edit_file(wc/<path>) editor fails → keep the temp dir, error "Failed to edit file", no operation tree_state.snapshot(…); new_tree = current_tree() tree IDs unchanged? yes → "Nothing changed.", no operation rewrite_commit(commit).set_tree(new) rebase_or_reparent_descendants tx.finish: write the operation (CLI page) no

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Target commitrewrittenA new commit with the new tree. Parents, change ID, description and author are kept (source).commits
Temporary TreeStatecreated, then discardedA second, private working copy: init, set_sparse_patterns, check_out, snapshot, current_tree. The same machinery the real working copy uses.working-copy
Trees and filesread, writtenThe checkout reads the file's content; the snapshot writes the new file object and the trees above it.trees
Conflictsmaterialized, then parsedWritten as markers in the configured style. On snapshot, markers that still parse keep the conflict; plain text resolves it.conflicts
DescendantsrewrittenRebased, or reparented with --restore-descendants, before tx.finish.transactions
ViewwriteHeads, bookmarks and wc_commit_ids follow the rewritten commits.view
OperationwriteOne operation, or none when the file is unchanged or the editor fails.operations
Real working copyread, then maybe writeSnapshotted first by the shared lifecycle. Updated at the end only if @'s tree changed.working-copy

5. Transaction and operation

  • Transaction: one, started only after the editor has exited and the snapshot differs (source). The editor runs outside any transaction.
  • Operation description: edit file <path> in commit <full hex id>, using the repo-internal path and the commit's id before the rewrite (source).
  • Commits rewritten: the target, then its descendants through rebase_or_reparent_descendants, which prints Rebased N descendant commits. only when N is above zero, adding (while preserving their content) when reparenting.
  • No change, or editor failure: no transaction and no operation; upstream's tests check the operation ID is unchanged in both cases (test, test).
  • jj undo restores the previous view, bringing back the old content in the target and its descendants.

6. Algorithm and invariants

What can be edited

The path's value in the commit's tree decides (source). A regular file or no value at all is fine; a missing path becomes a new file only if the editor saves it (test). A directory, symlink or Git submodule is refused. A conflict is accepted only if every term is a file or absent, which to_file_merge() checks.

A one-file working copy

Rather than writing a temporary file, the command creates a full TreeState in a temporary directory, with wc as the working copy and state for its state files, and narrows its sparse patterns to the single path before checking out the commit's tree (source). The file therefore sits at its real relative path, so an editor can detect its type from its name (test), and checkout and snapshot handle the executable bit and conflict markers the same way the real working copy does; upstream tests that an executable script stays executable (test).

Saving back

After the editor exits, the temporary copy is snapshotted with no ignore rules, tracking everything and no size limit (source). Since only the one path is in the sparse patterns, only that path can change. The new tree is compared with the commit's by tree IDs (source).

Conflicted files

The editor sees exactly what file show prints for the conflict (test). Changing a side inside the markers keeps the file conflicted with the new side, and the command reports New conflicts appeared in 1 commits (test). Replacing the markers with plain text resolves the conflict (test).

Descendants

By default rebase_descendants replays each descendant's own diff, so a descendant that never touched the file picks up the edit. With --restore-descendants, reparent_descendants keeps each descendant's tree, so it keeps the old content. file delete uses the same pair.

Invariants: only one path in the target's tree can change, and never its identity or metadata. Your real working copy is not used for editing. If the editor fails or the file is unchanged, nothing is recorded.

7. Errors and edge cases

MessageWhenSource
Path is a directory: …The path is a directory in the revision.edit.rs, test
Path '…' is not a regular fileThe path is a symlink or Git submodule.edit.rs
Path '…' has a non-file conflict and cannot be editedA conflict with a term that is not a file, such as a file-versus-directory conflict.edit.rs
Failed to edit file, then Hint: Edited file is left in …The editor could not be started or exited non-zero. The temporary directory is kept so the edit can be recovered.edit.rs, description_util.rs, command_error.rs, test
Nothing changed.The editor left the file as it was, or didn't create a new one.edit.rs, test
Commit … is immutableThe commit is immutable.cli_util.rs
Revset `…` resolved to more than one revision (or to none)-r isn't exactly one commit.revset_util.rs

8. Worked example

From upstream's test_file_edit. base adds file (containing base), dir/file and an executable script.sh; child, the working-copy commit, only adds other. The editor replaces file's content with modified, with and without --restore-descendants:

jj file edit -r=base file: before and after Before: base has file containing "base", and child at @ adds other. After a plain edit, base is rewritten with file containing "modified" and child is rebased, so its file says "modified" too. After an edit with --restore-descendants, base is rewritten the same way, but child is reparented with its tree unchanged, so its file still says "base". before jj file edit -r=base file … --restore-descendants base file: base child @ +other base 6b6a8e3b file: modified child d2c70bd0 @ rebased: file: modified base 0f101435 file: modified child 960b31ff @ reparented: file: base

Upstream's snapshot for the plain edit (source):

$ jj file edit -r=base file
Rebased 1 descendant commits.
Working copy  (@) now at: mzvwutvl d2c70bd0 child | child
Parent commit (@-)      : rlvkpnrz 6b6a8e3b base | base
Added 0 files, modified 1 files, removed 0 files

With --restore-descendants the first line becomes Rebased 1 descendant commits (while preserving their content). and there is no Added … removed … line, since @'s tree is unchanged (source).