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
| Argument | Type | Default | Meaning |
|---|---|---|---|
FILE (positional) | path | required | The one file to edit. It is a plain path, not a fileset. Completion offers every file in the revision (source). |
-r, --revision REVSET | revset | @ | The revision to edit the file in. It must be exactly one commit. Completion offers mutable revisions (source). |
--restore-descendants | bool | false | "Preserve the content (not the diff) when rebasing descendants" (source). |
FileEditArgs. The global flags are on the CLI page.
2. Configuration
| Key | Default | Effect |
|---|---|---|
ui.editor | nano (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-style | diff | How a conflicted file is written out for editing, and how markers are parsed back (source, misc.toml). |
revset-aliases."immutable_heads()" | built-in | Decides 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
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Target commit | rewritten | A new commit with the new tree. Parents, change ID, description and author are kept (source). | commits |
Temporary TreeState | created, then discarded | A 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 files | read, written | The checkout reads the file's content; the snapshot writes the new file object and the trees above it. | trees |
| Conflicts | materialized, then parsed | Written as markers in the configured style. On snapshot, markers that still parse keep the conflict; plain text resolves it. | conflicts |
| Descendants | rewritten | Rebased, or reparented with --restore-descendants, before tx.finish. | transactions |
View | write | Heads, bookmarks and wc_commit_ids follow the rewritten commits. | view |
| Operation | write | One operation, or none when the file is unchanged or the editor fails. | operations |
| Real working copy | read, then maybe write | Snapshotted 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 printsRebased 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 undorestores 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
| Message | When | Source |
|---|---|---|
Path is a directory: … | The path is a directory in the revision. | edit.rs, test |
Path '…' is not a regular file | The path is a symlink or Git submodule. | edit.rs |
Path '…' has a non-file conflict and cannot be edited | A 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 immutable | The 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:
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).