jj / How jj file chmod works

How jj file chmod works

jj file chmod sets or clears the executable bit that jj records for files in a commit. It changes the stored bit directly, so it works on any mutable revision, on Windows, and on conflicted files.

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. 7. Errors and edge cases
  8. 8. Worked example

In short: jj resolves -r (default @) and refuses it if immutable. For every file entry matching the paths, it sets executable on every term of the value: the one file of a resolved path, or every side and base of a conflict. Symlinks, directories and submodules are rejected. It rewrites the commit with the new tree, tx.finish rebases descendants, and the operation is make paths executable in commit <id> or make paths non-executable ….

The command is cmd_file_chmod. Its help text contrasts it with POSIX chmod: this one works on Windows, on conflicts, and on any revision (source). On disk, the bit only matters once the commit is checked out; see configuration.

1. Command-line surface

ArgumentTypeDefaultMeaning
MODE (positional)x | nrequiredx (alias executable) sets the bit; n (alias normal) clears it. The short names keep clap's error listing short (source).
FILESETS (positional)filesetsrequiredThe paths to change. At least one is needed (source).
-r, --revision REVSETrevset@The commit to change. It must be exactly one commit (source).

FileChmodArgs

2. Configuration

The command itself reads no keys besides revset-aliases."immutable_heads()", which decides whether the commit may be rewritten. working-copy.exec-bit-change (respect, ignore or auto, the default) decides what happens on disk. With ignore, jj neither reads nor sets the executable permission in the working copy, so jj file chmod is the way to change the recorded bit. Windows always behaves that way (docs). Upstream's tests for these settings are test_chmod_exec_bit_settings and the two that follow.

3. End-to-end flow

jj file chmod: call flow cmd_file_chmod maps the mode to a boolean, loads the workspace through the shared lifecycle, resolves -r (default @), checks it is rewritable, parses the paths and warns about unmatched ones, then starts a transaction. For each matching file entry in the commit's tree it rejects values whose sides are not all files, sets the executable flag on every term, and puts the value in a tree builder. It writes the new tree, rewrites the commit with it, and tx.finish records "make paths executable" or "non-executable" in commit, rebasing descendants. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) mode → bool; resolve -r; check_rewritable; parse paths; warn on unmatched; start_transaction x / executable → true, n / normal → false for each matching file entry every side a file (or absent)? no → error naming the path set executable on every term sides and bases alike; IDs unchanged tree_builder.set_or_remove_merge(path, value) write_tree; rewrite_commit(commit).set_tree(new tree).write() tx.finish(ui, "make paths [non-]executable in commit <id>"): rebase, write the operation (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Target commitrewrittenA new commit with the new tree. It keeps its parents, change ID, description and author.commits
TreeValue::Fileread, replacedOnly the executable field changes. File IDs and copy IDs stay the same, so no file content is written (source).trees
TreeswrittenNew tree objects along each changed path, via MergedTreeBuilder.trees
DescendantsrewrittenRebased by tx.finish. The bit change is part of the commit's diff, so it carries into descendants that didn't change it.transactions
ViewwriteHeads, bookmarks and wc_commit_ids follow the rewritten commits.view
OperationwriteOne operation.operations
Working copyread, then maybe writeSnapshotted first. If @ changed, the file's permission is updated on disk, subject to working-copy.exec-bit-change.working-copy

5. Transaction and operation

  • Transaction: one, started after the commit is checked and the paths are parsed (source).
  • Operation description: make paths executable in commit <full hex id> or make paths non-executable in commit <full hex id> (source).
  • Commits rewritten: the target, then its descendants in tx.finish (source).
  • No unchanged check: unlike diffedit or restore, the command doesn't compare trees before rewriting. Whether anything is recorded is left to tx.finish, which only skips when the repo has no changes at all (source).
  • An error aborts everything: the first bad path returns an error before the tree is written, so no other path changes either (source).
  • jj undo restores the previous view.

6. Algorithm and invariants

tree.entries_matching(matcher) yields every path in the commit's tree that the filesets match, each with its MergedTreeValue, a Merge of optional tree values (source). For each one:

  1. It checks that every side (each add term) that is present is a TreeValue::File. Absent sides, as in a modify/delete conflict, are skipped. If a side is something else, the error says Found neither a file nor a conflict for a resolved value and Some of the sides of the conflict are not files for a conflict (source).
  2. It then walks every present term, bases included, and sets executable to the requested value (source). Because every term gets the same value, the executable bit resolves to that value whatever the conflict's shape. Since only adds are checked, a base that isn't a file is left as it is; upstream has a TODO for a test of exactly that case (source).
  3. It stores the value back with set_or_remove_merge, which accepts the conflict because it keeps the base tree's arity, and unwraps the result (source).

Invariants: file contents and copy IDs never change, only the bit. A conflict stays a conflict with the same terms. Paths outside the filesets are untouched. Either every matched path changes or none does.

7. Errors and edge cases

MessageWhenSource
Found neither a file nor a conflict at '<path>'.A matched path is a symlink or submodule.chmod.rs
Some of the sides of the conflict are not files at '<path>'.A conflict side is a directory, symlink or submodule.chmod.rs
Warning: No matching entries for paths: …An explicit path isn't in the commit. The others still change.chmod.rs, cli_util.rs
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
clap: invalid value '…' for '<MODE>', listing n, xAn unknown mode.chmod.rs
clap: missing <FILESETS>No paths given.chmod.rs

8. Worked example

From upstream's test_chmod_regular_conflict. base has file; x and n both change it, and jj file chmod x file -r=x makes it executable in x. The merge conflict then has a 2-sided conflict whose terms disagree on the bit: x's side is executable, while the base and n's side are not. Running jj file chmod x file on the merge changes all three terms:

jj file chmod x on a conflicted file The conflicted value of file in the conflict commit has three terms: the side from x (executable true), the base from base (false), and the side from n (false). After jj file chmod x file, all three terms have executable true, with the same file IDs. The conflict and its markers are unchanged. Running jj file chmod n file sets all three to false. file in conflict, before after jj file chmod x file side 1 (x): 587be6b4… executable: true base (base): df967b96… executable: false side 2 (n): 8ba3a163… executable: false side 1 (x): 587be6b4… executable: true base (base): df967b96… executable: true side 2 (n): 8ba3a163… executable: true same IDs, new bits

jj debug tree before and after shows exactly this, with the file IDs unchanged, and jj file show file prints the same conflict markers both times. A later jj file chmod n file sets all three back to false. Passing an extra path that doesn't exist (source):

Warning: No matching entries for paths: nonexistent
Working copy  (@) now at: yostqsxw a1b4dce4 conflict | (conflict) conflict
Parent commit (@-)      : royxmykx 02247291 x | x
Parent commit (@-)      : zsuskuln eb0ba805 n | n
Added 0 files, modified 1 files, removed 0 files
Warning: There are unresolved conflicts at these paths:
file    2-sided conflict including an executable

In test_chmod_file_dir_deletion_conflicts, a file-vs-directory conflict is refused with Some of the sides of the conflict are not files at 'file'. A modify/delete conflict is accepted: the absent side is skipped, and the summary becomes 2-sided conflict including 1 deletion and an executable.