jj / How a command runs

How a command runs

Every jj command goes through the same machinery: load config, parse arguments, dispatch to a cmd_* function, load the repo at the current operation, snapshot the working copy, make changes in a transaction, then write and publish one new operation and update the files on disk. This page covers that shared lifecycle, so the command pages only need to cover what's specific to each command.

On this page
  1. 1. The lifecycle at a glance
  2. 2. Startup, config and dispatch
  3. 3. Loading the repo
  4. 4. Snapshotting the working copy
  5. Stale working copies
  6. 5. Making changes in a transaction
  7. 6. Finishing: rebase, export, write, publish
  8. 7. Global flags that change the lifecycle
  9. 8. Errors and exit codes

In short: a command never edits the repo in place. It loads an immutable ReadonlyRepo at the head operation, records the working copy's current state as a commit, makes its changes in a Transaction, and finishes by writing a new view and operation and moving the op heads to it. Undo, concurrency handling and --at-op all build on that.

1. The lifecycle at a glance

The lifecycle of a jj command main calls CliRunner::run, which loads config, expands aliases, parses arguments and dispatches through run_command to the command's cmd function. The command calls workspace_helper, which loads the workspace, resolves the operation to load, loads the repo and snapshots the working copy, possibly recording a "snapshot working copy" operation. The command then calls start_transaction, edits the MutableRepo, and calls finish, which rebases mutable descendants, exports to Git if colocated, writes the view, operation and index, publishes by updating op heads, and updates the working copy on disk. CliRunner::run config, aliases, clap parse run_command match → cmd_<name>(ui, helper, args) workspace_helper load workspace + repo at op head snapshot files on disk → working-copy commit start_transaction command edits the MutableRepo finish(ui, description) rebase descendants, export to Git update working copy what reaches the store resolve_operation --at-op, or op heads (merged if divergent) "snapshot working copy" operation only if the files on disk changed Transaction::write write_view · write_operation · write_index UnpublishedOperation::publish lock op heads · update_op_heads

2. Startup, config and dispatch

main is one line: CliRunner::init().version(…).run(). CliRunner::init assembles everything a custom jj binary can override: the clap app, default config layers, config migrations, store factories (backends), working-copy factories, the workspace-loader factory, revset and template extensions, and the dispatch function, which defaults to commands::run_command.

run_internal then:

  1. Canonicalizes the current directory and finds the nearest workspace, if any, so that repo and workspace config can be read before parsing arguments.
  2. Loads config layers in order: system, user, repo, then workspace. Then it resolves and migrates the config, because aliases and the default command depend on config.
  3. Expands aliases, parses the "early" arguments (--config, --config-file, --color, …) and adds any config they supply, then runs the full clap parse.
  4. If -R/--repository was given, it reloads repo and workspace config from that path instead.
  5. Builds UserSettings and a CommandHelper wrapping everything above, then calls the dispatch function, wrapped in any dispatch hooks.

run_command is one big match on the parsed Command enum, calling <module>::cmd_<name>(ui, command_helper, args). Commands with subcommands, such as git, bookmark and operation, dispatch again inside their module. git and gerrit exist only with the git feature, and bench only with bench.

3. Loading the repo

Almost every command starts with command.workspace_helper(ui), which calls workspace_helper_with_stats:

  1. load_workspace finds .jj/ and constructs the Workspace with the configured store and working-copy factories.
  2. If the working copy is writable, it takes the Git import/export lock first, so the repo it loads matches the op head that Git refs will be synced against. Writable means no --at-op (or --at-op=@) and no --ignore-working-copy (source).
  3. resolve_operation picks the operation to load. With --at-op it resolves that operation. Otherwise it reads the op heads, and if there's more than one (concurrent commands), it prints Concurrent modification detected, resolving automatically. and merges them into a new operation described reconcile divergent operations.
  4. workspace.load_at(&op) builds the ReadonlyRepo: that operation, its view, and its index (source).
  5. If the working copy may be snapshotted, it snapshots it (§4) and reports any untracked files.

Read-only commands that must not touch the working copy use workspace_helper_no_snapshot instead.

4. Snapshotting the working copy

In jj, the files on disk are a commit, @. Before a command runs, snapshot_impl records them:

  1. In a repo colocated with Git, it first imports Git's HEAD, in case git moved it.
  2. snapshot_working_copy locks the working copy and checks it isn't stale (below). It then asks the working copy for a new tree. The local working copy skips re-reading any file whose type, mtime and size still match its stored FileState (FileState::is_clean).
  3. If the tree changed, it opens a transaction marked is_snapshot and rewrites the working-copy commit with the new tree. If @ has become immutable, it instead creates a new commit on top and warns: The working-copy commit is immutable; a new commit has been created on top of it. It then updates wc_commit_ids, rebases descendants, and commits the transaction as an operation described snapshot working copy.
  4. When colocated, it imports Git refs last, which may itself rebase the working-copy commit.

Stale working copies

The working copy records the operation it was last updated at (Checkout.operation_id). handle_stale_working_copy compares it with the loaded repo, using WorkingCopyFreshness::check_stale:

FreshnessMeaningWhat happens
FreshSame operation as the repo.Continue.
Updated(op)Another process updated the working copy after the repo was loaded.Reload the repo at that operation and continue.
WorkingCopyStaleThe working copy is behind the repo, and its checked-out tree differs from @'s. A working copy that's behind but already matches counts as Fresh.Error: The working copy is stale (not updated since operation …), with a hint to run jj workspace update-stale. If snapshot.auto-update-stale is set, jj recovers automatically instead.
SiblingOperationThe repo and working copy are on sibling operations.Internal error with a hint to run jj op integrate <op>.
operation missingThe working copy's operation can't be read.Error: Could not read working copy's operation.

5. Making changes in a transaction

A mutating command calls start_transaction. It wraps jj-lib's Transaction (a MutableRepo over the loaded ReadonlyRepo) and tags it via start_repo_transaction: the workspace name, and an args attribute holding the full command line, shell-quoted (source). That's how jj op log can show exactly which command made each operation. The command then works through tx.repo_mut(): it writes commits, rewrites them, and moves bookmarks or the working-copy commit.

6. Finishing: rebase, export, write, publish

WorkspaceCommandTransaction::finish(ui, description):

  1. If nothing changed, it prints Nothing changed. and records no operation at all.
  2. rebase_mutable_descendants rebases every descendant of a rewritten commit, skipping commits that were already immutable, and reports Rebased N descendant commits.
  3. It takes the Git import/export lock, then finish_transaction runs. If the new @ is immutable, it creates a fresh commit on top. When colocated, it resets Git's HEAD and index and exports jj's refs to Git (jj_lib::git::export_refs).
  4. maybe_commit_transaction calls Transaction::write, which writes the view, then an operation pointing at it (with the parent operation, metadata, and the commit_predecessors map of what was rewritten from what), then the index for that operation. Nothing is visible yet.
  5. publish takes the op-heads lock and replaces the parent op heads with the new operation. From this moment, other jj processes see the change. With --no-integrate-operation, this step is skipped and the operation ID is printed instead (leave_unpublished).
  6. The working copy on disk is updated to the new @, which prints Working copy (@) now at: … and warns about conflicts. Finally, repo changes are reported, along with a warning if user.name or user.email isn't configured.

Because the operation is published before the working copy is updated, a crash in between leaves the working copy merely stale, never lost. The next command detects it (§4), and jj workspace update-stale repairs it.

7. Global flags that change the lifecycle

FlagEffect on the lifecycle
-R, --repository <PATH>Load this workspace instead of searching up from the current directory. Its repo and workspace config are loaded too.
--ignore-working-copySkip the snapshot at the start, and don't update the working copy at the end.
--at-operation, --at-op <OP>Load the repo at that operation instead of the op heads. This implies --ignore-working-copy unless the value is @. Mutating commands still work, as if they'd run concurrently from that point.
--no-integrate-operationWrite the operation but don't publish it. Print its ID instead, and don't update the working copy. Side effects outside the repo, such as a git push, still happen.
--ignore-immutableDisable the immutability check on rewrites, except for the root commit.
--debugTurn on debug logging.
--color, --quiet, --no-pager, --config, --config-file"Early" arguments, parsed before the rest because they affect config and output.

GlobalArgs and EarlyArgs

8. Errors and exit codes

Commands return Result<(), CommandError>. A CommandError has a kind, the error, and optional hints. handle_command_result (clap errors) maps the kind to an exit code:

KindPrinted asExit code
success0
UserError: …1
ConfigConfig error: … plus a pointer to the config docs1
Cliclap's rendered message, or Error: … for other CLI errors. --help and --version also go through this path and print to stdout.2 (0 for --help/--version)
BrokenPipenothing; the output was closed (e.g. piped to head)3
InternalInternal error: …255