Skip to content

Instantly share code, notes, and snippets.

@Neonsy
Last active September 25, 2026 22:19
Show Gist options
  • Select an option

  • Save Neonsy/79a6e7508735ac5e264a6834f45f5509 to your computer and use it in GitHub Desktop.

Select an option

Save Neonsy/79a6e7508735ac5e264a6834f45f5509 to your computer and use it in GitHub Desktop.
JJ VSC Setup Guide (Powershell & ZSH)

Jujutsu (jj) Setup and Daily-Use Guide

Important

This guide is written and checked for Jujutsu 0.45.1

jj is still evolving quickly. If you use a newer release, check its changelog when a command or setting behaves differently

This guide takes you from installing jj to a comfortable daily workflow, then covers everything else for when you need it. Part 1 is all you need for everyday work. Part 2 goes deeper, one topic at a time

It is written for people who are new to jj and know at least the basics of Git. You need Git 2.41 or newer and a terminal: PowerShell on Windows, or Bash or Zsh on Ubuntu. Any text editor works

It covers:

  • Windows with PowerShell
  • Ubuntu with Bash or Zsh
  • Git repositories, so GitHub, your team, and Git tools keep working

The guide comes with an optional set of shell shortcuts. Every command is shown in full, and where a shortcut exists it appears as a comment next to it:

jj status        # js

You never have to use the shortcuts, but they save a lot of typing in the daily flow

Contents

Part 1: Daily use

  1. What jj is
  2. Install jj
  3. Configure jj
  4. Set up your shell
  5. Open or create a repository
  6. The everyday loop
  7. Common tasks

Part 2: In depth

  1. Look around
  2. Sync with a remote
  3. Reshape history
  4. Resolve conflicts
  5. Undo and recover
  6. Bookmarks and tracking
  7. Tags
  8. Revsets
  9. Run commands across revisions
  10. Workspaces and sparse checkouts
  11. Sign commits with SSH
  12. Customize the log
  13. Maintenance
  14. Shortcut reference

Appendices


Part 1: Daily use

1. What jj is

Jujutsu is a version control system that works on top of Git repositories. Your team, GitHub, and your Git tools see an ordinary Git repository, and you get a simpler way of working locally

Five ideas explain most of the difference:

  • There is no staging area. The files you are editing are themselves a change, called @. jj saves your edits into it automatically every time you run a jj command, so there is no git add
  • Changes keep their ID. Every change has a change ID, such as kxmwmlvk, that stays the same when you reword, edit, or move the change. Use change IDs to refer to your work. You only need to type the first few letters, and jj log highlights how many
  • Edits ripple forward. When you edit an earlier change, everything built on top of it is updated automatically
  • Conflicts don't block you. A conflict is stored inside the change. You can finish what you were doing and resolve it later
  • Mistakes can be undone. Every command that changes the repository is recorded, and jj undo reverses the last one

Words you'll see

Term Meaning
@ The change you are working in right now
@- The change just below @, its parent
Change One piece of work. It keeps its change ID when rewritten
Revision Anything that names a change: A change ID, a commit ID, a bookmark, or an expression such as @-
Bookmark A name that points at a change, like a Git branch
trunk() Your project's main line, usually main on the remote
Stack Your changes that are not in trunk() yet, from the oldest up to @
Immutable Protected from rewriting. The main line and tags are immutable, but your own work is not

Coming from Git

Git jj Shortcut
git status jj status js
git diff jj diff jd
git log jj log jl
git add Not needed
git commit -m "..." jj commit -m "..." jcm ...
git commit --amend jj squash moves your new edits into the previous change jsq
git switch -c NAME main jj new main, then name it when you publish jn main
git stash Not needed: Start other work with jj new main and come back with jj edit jn, je
git restore FILE jj restore FILE jdiscard FILE
git pull jj git fetch, then jj rebase jup
git push jj git push --bookmark NAME jpub NAME
git cherry-pick jj duplicate jdupe
git revert jj revert jrevert
git reflog jj op log and jj undo jop, jundo

Note

This guide uses colocated repositories, which contain both a .jj and a .git directory. Git tools, such as VS Code's Source Control view, keep working alongside jj


2. Install jj

jj needs Git 2.41 or newer. Check with:

git --version

If Git is missing or too old on Windows, install it with winget install Git.Git. Ubuntu 22.04 ships Git 2.34, which is too old. Install a current Git from the Git maintainers' PPA:

sudo add-apt-repository ppa:git-core/ppa
sudo apt update
sudo apt install git

Ubuntu 24.04 and newer already ship a recent enough Git

Windows

winget install jj-vcs.jj

Upgrade later with winget upgrade jj-vcs.jj

Ubuntu

There is no official apt package. Download the prebuilt binary into ~/.local/bin:

mkdir -p ~/.local/bin
curl -fsSL https://github.com/jj-vcs/jj/releases/download/v0.45.1/jj-v0.45.1-x86_64-unknown-linux-musl.tar.gz \
  | tar -xz -C ~/.local/bin ./jj

On ARM machines, replace x86_64 with aarch64. If jj is not found afterwards, log out and back in so that ~/.local/bin is added to your PATH

To upgrade later, run the same command with the new version number in both places

Check the installation

jj version

The result should begin with jj 0.45.1


3. Configure jj

Set your name and email. Every change records them:

jj config set --user user.name "Your Name"
jj config set --user user.email "your-email@example.com"

Choose an editor (optional)

jj opens an editor when you write a description without -m, for example with jj commit or jj describe. Without any setting, it uses your $EDITOR, or Notepad on Windows and nano on Ubuntu. Conflicts open in jj's built-in terminal merge tool. Both work without any setup

To use VS Code instead, if you have it installed:

# Windows
jj config set --user ui.editor "code.cmd -w"
jj config set --user ui.merge-editor "vscode"
# Ubuntu
jj config set --user ui.editor "code -w"
jj config set --user ui.merge-editor "vscode"

-w makes jj wait until you close the tab. Any other editor works the same way, for example jj config set --user ui.editor vim. For conflicts, jj also knows meld, kdiff3, vimdiff, vscodium, smerge, and mergiraf without extra setup

Interactive commands such as jj split always use jj's built-in terminal diff editor

Recommended extras

Open your configuration file in your editor:

jj config edit --user

Add the following:

#:schema https://docs.jj-vcs.dev/latest/config-schema.json

[snapshot]
# Repair a stale working copy automatically instead of asking you to.
auto-update-stale = true

[revset-aliases]
# Your changes that have edits but no description yet.
"wip()" = 'mine() & mutable() & description(exact:"") & ~empty()'
# Empty changes left below where you are working.
"stale()" = "ancestors(@-) & mutable() & empty() & ~merges()"
  • The #:schema line lets a TOML extension such as Even Better TOML validate the file and complete setting names
  • auto-update-stale handles a rare situation, such as an interrupted command or another workspace rewriting your change, without stopping to ask you to run jj workspace update-stale
  • The two aliases add wip() and stale() to the revset language. The jwip and jstale shortcuts use them

Useful configuration commands:

jj config path --user     # Where the file is
jj config list            # Every setting currently in effect

4. Set up your shell

Tab completion

Completion suggests commands, flags, bookmarks, change IDs, and paths when you press Tab

PowerShell. If you have never used a PowerShell profile, create the file first:

if (-not (Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force }

Open it with notepad $PROFILE and add:

$env:COMPLETE = "powershell"
try {
    jj | Out-String | Invoke-Expression
}
finally {
    Remove-Item Env:\COMPLETE -ErrorAction SilentlyContinue
}

Save, then reload with . $PROFILE. If PowerShell refuses to load the profile, allow local scripts for your account:

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

Zsh. Add to ~/.zshrc, then run source ~/.zshrc:

autoload -Uz compinit
compinit
source <(COMPLETE=zsh jj)

Bash. Add to ~/.bashrc, then run source ~/.bashrc:

source <(COMPLETE=bash jj)

Shortcuts

The shortcut profile adds 125 short commands. You do not need to learn them all: The dozen in the everyday loop cover most days, and the shortcut reference lists the rest

To install them:

Both versions have the same shortcuts and behave the same way. None of them bypass jj's protection of published history, and the few that discard things in bulk (jclean, jopclean, jtagmirror) show what they will do and ask first

How the names work

Every shortcut starts with j. The rest follows a few patterns:

Pattern Meaning Examples
j + the start of a command That jj command js status, jl log, jd diff, jn new, je edit, ja abandon, jsq squash, jrb rebase
Ends in m Takes a message, no quotes needed jm describe, jnm new, jcm commit
jb… Bookmarks jbc create, jbm move, jbd delete, jbl list
jt… Tags jtc create, jtm move, jtd delete, jtl list
jg… Git remotes jgf fetch, jgp push, jgra add a remote
jop… The operation log jop log, jopshow, joprevert, joprestore
jf… Files in a revision jfshow, jflist, jfa annotate

Where a short name would be unclear, the word is spelled out: jrestore, jrevert, jdiscard, jdupe, jsplit, jundo, jpub (publish), jup (update)

How shortcuts read arguments

Many jj commands treat a bare argument as a file path. For example, jj restore abc restores a file named abc. The shortcuts fill in the flag you almost always mean instead:

  1. Plain values fill the shortcut's slots in order. jrestore abc runs jj restore --from abc, and jbm feat abc moves bookmark feat to abc
  2. Anything after the slots goes to jj unchanged, whether flags or file paths: jrestore abc src/app.ts --interactive
  3. If the first argument is a flag, no slots are filled. You are writing normal jj arguments: jrestore --changes-in abc
  4. Defaults apply only when you have not said otherwise. jrevert abc places the revert below @, but jrevert abc --onto main places it on main

When you leave out a revision, it defaults to @. The publishing shortcuts jpub, jpc, and jtsp are smarter: If @ is empty and has no description, as it is right after jcm, they use @- instead and tell you so

Shortcuts that talk to a remote use the repository's only remote. If there are several, pass the remote name as the last argument

Important

In PowerShell, quote every revision that starts with @, such as '@-'. PowerShell treats an unquoted @ as its own syntax and stops with Unrecognized token in source text before jj runs. Single quotes work in every shell, so this guide uses them throughout


5. Open or create a repository

Clone an existing project:

jj git clone https://github.com/owner/project.git        # jclone URL
cd project

Start using jj in a Git repository you already have, from its root folder:

jj git init --colocate        # ji

Create a brand-new repository:

mkdir my-project
cd my-project
jj git init

Then take a look:

jj status        # js
jj log           # jl

If jj log does not show your main bookmark, see Bookmarks and tracking


6. The everyday loop

This is the workflow for most days. Each step shows the shortcut and the full command

1. Start a change on top of main

jj new main        # jn main

2. Edit files, then check your work

There is no add step. Edit your files, then check them:

jj status        # js
jj diff          # jd

3. Finish the change

jj commit -m "Add the login form"        # jcm Add the login form

jj commit gives your change a description and starts a new, empty change on top, ready for the next piece of work. Repeat steps 2 and 3 as often as you like

Your history now looks like this in jj log:

@  qktosxrt you@example.com 2026-09-25 23:31:58 70dee824
│  (empty) (no description set)
○  nonwzlvy you@example.com 2026-09-25 23:31:58 d343dcb0
│  Validate login input
○  zomwqpvz you@example.com 2026-09-25 23:31:58 1ba6307f
│  Add the login form
◆  vyzvlssm you@example.com 2026-09-25 23:31:57 main bc650d6a
│  Update the README
~

@ is the fresh empty change you are in. @- is the change you just finished. The diamond ◆ marks immutable history

4. Publish

jpub feature/login

jpub points the bookmark feature/login at your latest finished change and pushes it. Run the same command again whenever you have finished more work. Without the shortcut:

jj bookmark set feature/login -r '@-'
jj git push --bookmark feature/login

5. Catch up with main

jup

jup fetches from the remote and moves your work on top of the latest main line:

jj git fetch
jj rebase --onto 'trunk()'

If you had already published, run jpub feature/login again to update the remote

Made a mistake?

jj undo        # jundo

Undo reverses local operations such as rebases, squashes, abandons, and bookmark moves. Run it again to go further back. It cannot take back a push that has already reached the remote

Small variations

  • Fix the last change instead of starting a new one: Make the edits, then run jsq (jj squash). It moves them into @-, like git commit --amend
  • Reword a description: jm New wording rewords @. For the change you just finished, use jj describe '@-' -m "New wording"
  • Describe first, work second: jnm Add the login form starts a change that already has its description. Edit, then run jnm again for the next change
  • Edit an older change directly: je CHANGE_ID makes it @, and the changes above it update automatically as you edit. When you are done, go back with je and the ID of your newest change

7. Common tasks

Find what you want to do, then use the shortcut or the full command. abc and def stand for change IDs, and NAME for a bookmark

Look around

I want to Shortcut Full command
See what I changed jd jj diff
See only the changed file names jfiles jj diff --summary
See my stack jstack jj log -r 'trunk()..@'
See status and my stack together jnow jj status, then the above
See one change in full jsh abc jj show abc
Compare two changes jd abc def jj diff --from abc --to def
See how a change evolved jevo abc jj evolog -r abc
See the whole history, not just recent work jla jj log -r 'all()'

Work on changes

I want to Shortcut Full command
Start new work on main jn main jj new main
Finish this change and start the next jcm Message jj commit -m "Message"
Reword the current change jm Message jj describe -m "Message"
Go back and edit an earlier change je abc jj edit abc
Add my new edits to the previous change jsq jj squash
Add my new edits to a specific change jsqi abc jj squash --into abc
Choose which lines to move to the previous change jsqp jj squash --interactive
Send each fix to the earlier change it belongs to jabs jj absorb
Split the current change in two jsplit jj split
Throw away my current edits jdiscard [PATHS] jj restore [PATHS]
Delete a change ja abc jj abandon abc

Reuse work

I want to Shortcut Full command
Copy a change and continue on the copy jdupe abc jj duplicate abc, then jj edit the new ID
Copy a change on top of another one jdupe abc --onto main jj duplicate abc --onto main, then jj edit
Bring files back from another change jrestore abc [PATHS] jj restore --from abc [PATHS]
Add a change that undoes an earlier one jrevert abc jj revert -r abc --insert-before '@'

Move work

I want to Shortcut Full command
Move my stack onto the latest main jup jj git fetch, then jj rebase --onto 'trunk()'
Move my stack somewhere else jrb main jj rebase --onto main
Move a change and everything above it jrbs abc main jj rebase --source abc --onto main
Move only one change jrbr abc main jj rebase --revisions abc --onto main

Share work

I want to Shortcut Full command
Publish my work under a name jpub NAME jj bookmark set NAME -r '@-', then jj git push --bookmark NAME
Publish without choosing a name jpc jj git push --change '@-'
Preview a push jgpp NAME jj git push --bookmark NAME --dry-run
Move a bookmark anywhere jbm NAME abc jj bookmark move NAME --to abc --allow-backwards
Delete a bookmark here and on the remote jbdp NAME jj bookmark delete NAME, then jj git push --bookmark NAME
Tag a release and push the tag jtsp v1.2.0 jj tag set v1.2.0 -r '@-', then jj git push --tag v1.2.0

Fix mistakes

I want to Shortcut Full command
Undo the last command jundo jj undo
Redo what I undid jredo jj redo
See every command I have run jop jj op log

Part 2: In depth

8. Look around

Inspect

jj status                 # js
jj log                    # jl
jj show                   # jsh
jj diff                   # jd
jj diff --stat            # jds
jj diff --summary         # jfiles

Variations:

# Log a revset
jj log -r 'main..@'                         # jl 'main..@'

# Show one change, or several oldest-first
jj show abc                                 # jsh abc
jj show --reversed -r 'trunk()..@'          # jreview

# Diff one change, or compare two
jj diff -r abc                              # jd abc
jj diff --from main --to '@'                # jd main '@'

# Compare what two versions of a change did
jj interdiff --from abc --to def            # jidiff abc def

# See every earlier version of a change
jj evolog -r abc                            # jevo abc

jstack and jreview also accept a bookmark or change: jstack feature/login shows the changes on that bookmark that are not yet in trunk()

Move around

Start a new change on top of any revision:

jj new abc        # jn abc

Edit an existing change directly:

jj edit abc       # je abc
jj edit '@-'      # jep

Step through a stack:

jj prev           # jprev
jj next           # jnext

By default, prev and next create a new empty change at the destination. Add --edit to edit the destination directly instead


9. Sync with a remote

jj has no pull command. Fetching and rebasing are separate steps, and jup combines them

Fetch

jj git fetch                       # jgf
jj git fetch --remote upstream     # jgf upstream
jj git fetch --all-remotes         # jgfa

Rebase onto the latest main line

jj rebase --onto 'trunk()'         # jrb 'trunk()'

Without --source or --revisions, jj rebase moves your whole stack: Every change between trunk() and @. Descendants always come along

jup fetches and then runs exactly this rebase. Pass a remote name to fetch only that remote: jup upstream

Note

Some people configure jj to treat everything they have pushed as immutable (see Immutable history). With that policy, jup stops with Commit ... is immutable once your stack has been published. That is the policy doing its job. If you really mean to rewrite the published changes, run jj git fetch and then jj --ignore-immutable rebase --onto 'trunk()'

Push

jpub, from the everyday loop, names your work and pushes it in one step:

jpub feature/login               # Name your latest finished change and push it
jpub feature/login abc           # Name and push a specific change instead
jpc                              # Push with an automatically generated bookmark name

When the bookmark already points where you want, push it directly:

jj git push --bookmark feature/login              # jgp feature/login
jj git push --bookmark feature/login --dry-run    # jgpp feature/login

A plain jj git push (jgp) pushes every tracked bookmark and tag that points between the remote's bookmarks and @

Pushing is safe by default. When a bookmark was rewritten, for example after jup, jj updates the remote only if the remote still matches what you last fetched, like Git's --force-with-lease. You do not need a separate force option for normal work

For the rare case where you must overwrite the remote regardless of what is there:

jgpforce feature/login

jgpforce force-pushes only the named bookmark through Git, then fetches the result

Caution

An unconditional force-push can overwrite work that others pushed since your last fetch. Use it only when that is what you intend

Push every bookmark and tag at once:

jj git push --all        # jgpa

Warning

--all is broad and, since jj 0.44, also pushes every tag. Prefer pushing one named bookmark

Delete a bookmark locally and remotely

jbdp feature/login

Without the shortcut:

jj bookmark delete feature/login
jj git push --bookmark feature/login

Deleting a bookmark does not delete its changes. They remain recoverable through the operation log


10. Reshape history

jj is built for editing local history. A change keeps its change ID when you rewrite it, but gets a new commit ID

Squash

# Move all edits in @ into its parent
jj squash                      # jsq

# Squash a specific change into its parent
jj squash -r abc               # jsq abc

# Choose which lines to move
jj squash --interactive        # jsqp

# Move edits from @ into any mutable change
jj squash --into abc           # jsqi abc

# Only certain files
jj squash --into abc src/app.ts        # jsqi abc src/app.ts

Split

jj split                       # jsplit
jj split -r abc                # jsplit abc

The built-in editor lets you choose what belongs in the first change, and the rest becomes a second change on top

Absorb

jj absorb                      # jabs
jj absorb --interactive        # jabs --interactive

absorb moves each edit in @ into the earlier change that last touched those lines. Use it to apply review fixes across a stack

Rebase

# Move your stack
jj rebase --onto main                           # jrb main

# Move a change and everything above it
jj rebase --source abc --onto main              # jrbs abc main

# Move only certain changes. Their children stay in place
jj rebase --revisions abc --onto main           # jrbr abc main

Duplicate

jj duplicate abc               # Copy it and stay where you are
jj duplicate abc --onto main   # Copy it somewhere else

jdupe duplicates and then switches to the copy so that you can edit it straight away:

jdupe                          # Copy @
jdupe abc
jdupe abc --onto main

If you copy several changes at once, jdupe stays where you are and prints the new IDs

Other tools

Command Shortcut Use it to
jj arrange jarr Reorder and move changes interactively in one screen
jj parallelize REVSET jpar Turn a line of changes into siblings
jj diffedit -r abc jdiffedit abc Edit a change's content in the diff editor
jj fix -s abc jfix abc Run configured formatters over changes
jj simplify-parents -r abc jsimp abc Remove redundant parent edges
jj metaedit jme Change a change's author, timestamps, or change ID without touching its content

Divergent changes

A change is divergent when two visible commits share its change ID, for example after it was rewritten in two places. Find and combine them:

jj log -r 'divergent()'        # jdiv
jj converge                    # jconv

jj converge asks for help when it cannot decide how to combine them


11. Resolve conflicts

A conflict is stored in the change, so a rebase can finish and leave the conflict for you to resolve when convenient

jj log -r 'conflicts()'        # jconf     Which changes have conflicts
jj resolve --list              # jresl     Which files in @ are conflicted
jj resolve                     # jres      Open each one in your merge tool
jj resolve path/to/file        # jres path/to/file

jj resolve uses jj's built-in terminal tool, or the merge tool you chose in Configure jj. Save the result and close the tool

You can also fix a conflicted file by hand: Remove the conflict markers, keep the right content, and save. jj notices on the next command

To resolve a conflict in an earlier change, edit it first with je abc, or start a change on top of it with jn abc, resolve, and then jsq


12. Undo and recover

Every command that changes the repository is recorded in the operation log

jj undo            # jundo     Undo the last operation. Repeat to go further back
jj redo            # jredo
jj op log          # jop       List operations
jj op show OP      # jopshow   What one operation changed
jj op diff --from OP_A --to OP_B        # jopdiff

Choose the right tool

Command Shortcut Use it when
jj undo jundo The last thing you did was a mistake
jj op revert OP joprevert OP One particular earlier operation should be reversed, keeping everything since
jj op restore OP joprestore OP The whole repository should go back to how it was at that operation
jj revert -r abc --insert-before '@' jrevert abc You want a new change that undoes the code of an earlier change

Caution

op restore also undoes everything after that operation. Look at the state first:

jj --at-op=OP log

13. Bookmarks and tracking

A bookmark is a name that points at a change. Unlike a Git branch, it does not move when you make new changes. You move it yourself, or jpub moves it for you

Goal Command Shortcut
List jj bookmark list --all-remotes jbl --all-remotes
Create at a change jj bookmark create NAME -r abc jbc NAME abc
Create, or move forward jj bookmark set NAME -r abc jbs NAME abc
Move in any direction jj bookmark move NAME --to abc --allow-backwards jbm NAME abc
Move the nearest bookmark up to @ jj bookmark advance jba
Rename jj bookmark rename OLD NEW jbr OLD NEW
Delete jj bookmark delete NAME jbd NAME
Delete here and on the remote jbdp NAME

If the revision is left out, these use @. jbm refuses to create a bookmark that does not exist, so a typo cannot create a new one

Tracking

Tracking links a local bookmark to the remote bookmark with the same name, so fetches update it and jj git push knows where it belongs. Pushing a new bookmark tracks it automatically

jj bookmark track main@origin          # jbt main@origin
jj bookmark untrack main@origin        # jbu main@origin

When you add jj to an existing Git repository, check that your main line is tracked:

jj bookmark list --all-remotes
jj bookmark track main@origin

You can automate tracking per remote. Pick the policy that fits the repository, and do not copy one blindly into a shared configuration:

# Track every bookmark on this remote, suitable for a personal fork
[remotes.origin]
auto-track-bookmarks = "*"
# Track only bookmarks you create
[remotes.origin]
auto-track-created-bookmarks = "*"
# Track only your own namespace
[remotes.origin]
auto-track-bookmarks = "yourname/*"

14. Tags

Since jj 0.44, tags work much like bookmarks: jj git fetch fetches remote tags as NAME@REMOTE and tracks them, and jj git push --tag pushes them

Goal Command Shortcut
List jj tag list jtl
List with remote state jj tag list --all-remotes jtla
Create jj tag set v1.2.0 -r abc jtc v1.2.0 abc
Move jj tag set v1.2.0 -r abc --allow-move jtm v1.2.0 abc
Delete jj tag delete v1.2.0 jtd v1.2.0
Push one tag jj git push --tag v1.2.0 jtp v1.2.0
Create and push jtsp v1.2.0 [abc]
Delete here and on the remote jtdp v1.2.0
List the remote's tags without fetching git ls-remote --tags --refs origin jtrl

jj tag set refuses to move an existing tag unless you add --allow-move, so jtc cannot move a tag by accident

A tag you create locally is not tracked until you push it with --tag or run jj tag track NAME@REMOTE. Once tracked, a plain jj git push also pushes it when it is in the default push range. If a tracked tag is deleted on the remote, the next fetch deletes it locally too. To stop fetching tags from a remote, set remotes.<name>.fetch-tags = "~*"

To push every pending tag change at once:

jj git push --tag '*' --dry-run        # Preview
jtagmirror                             # Preview, then push after you type yes

jtagmirror pushes every new, moved, and deleted tag. Tags that exist only on the remote are not deleted. jj git fetch brings them in instead


15. Revsets

A revset is an expression that selects changes. Anything that takes a revision accepts one, for example jj log -r REVSET or jl REVSET

Common expressions

jj log -r '@'                              # The current change
jj log -r '@- | @--'                       # Parent and grandparent
jj log -r 'trunk()..@'                     # Your changes not yet in the main line
jj log -r 'main::'                         # main and everything built on it
jj log -r 'mine() & mutable()'             # Your rewritable changes (jmine)
jj log -r 'conflicts()'                    # Changes with conflicts (jconf)
jj log -r 'empty()'                        # Empty changes
jj log -r 'files("src/main.rs")'           # Changes touching a path
jj log -r 'description(substring:"login")' # Descriptions containing "login"
jj log -r 'remote_bookmarks()'             # Every remote bookmark
jj log -r 'forks()'                        # Changes with more than one child

x..y means "reachable from y but not from x". x::y means "descendants of x that are ancestors of y"

The wip() and stale() aliases from Configure jj work anywhere a revset does. jprivate logs the git.private-commits revset, which lists changes jj must never push. It is empty until you define it

Immutable history

jj refuses to rewrite immutable history. By default, that is:

  • trunk(), tags, and untracked remote bookmarks and tags
  • All of their ancestors
  • The root commit
jj log -r 'immutable()'

To rewrite something immutable anyway, say so explicitly:

jj --ignore-immutable describe abc -m "Corrected message"

Warning

Do not turn --ignore-immutable into a shortcut. Rewriting published history should stay a visible, deliberate choice. The safer alternative is a new change on top: jj new main -m "Fix the published change"

Some people also protect everything they have pushed by adding this to their configuration:

[revset-aliases]
"immutable_heads()" = "builtin_immutable_heads() | remote_bookmarks(remote=origin)"

With it, a pushed change can no longer be squashed into, split, or rebased by jup. It is a valid choice, but it makes the everyday loop stricter: Once published, new work goes in new changes on top


16. Run commands across revisions

jj run checks out each selected revision in a separate working copy, runs a command in it, and keeps any files the command changes. It goes from oldest to newest and stops at the first failure

jj run -r '@' -- npm test                       # jrun npm test
jj run -r 'trunk()..@' -- npm test              # jrunstack npm test
jj run -r 'trunk()..@' -j 4 -- npm run lint     # jrunstack -j 4 npm run lint

Without -r, jj run selects every mutable revision connected to @, not only the current one. jrun therefore defaults to -r '@'. It also adds the -- separator, which PowerShell would otherwise remove

Option Effect
--ignore-changes Run without rewriting anything, which suits tests
--ignore-errors Continue after a failure
--passthrough Show the command's output live. Works with one job only

Put options before the command: jrun --ignore-changes npm test

Caution

A command that changes files rewrites every selected revision. Check the selection first with jj log -r REVSET


17. Workspaces and sparse checkouts

Workspaces

A workspace is another folder attached to the same repository, with its own @. Use one to review or test something without disturbing your current work

jj workspace add ../project-review        # jws add ../project-review
jj workspace list                         # jws list
jj workspace forget NAME                  # jws forget NAME

If a workspace reports that it is stale, run jj workspace update-stale. The auto-update-stale setting from Configure jj normally does this for you

Sparse checkouts

Sparse patterns limit which paths appear in the working copy, which helps in very large repositories

jj sparse set --clear --add README.md --add src    # Keep only these
jj sparse set --add docs                           # Add a path
jj sparse set --remove docs                        # Remove a path
jj sparse list                                     # Show the patterns
jj sparse reset                                    # Bring back everything
jj sparse edit                                     # Edit the patterns in your editor

jsparse is a shortcut for jj sparse


18. Sign commits with SSH

Signing is optional. Set it up when your hosting provider or project requires verified commits

Create a key

ssh-keygen -t ed25519 -C "your-email@example.com"

Protect it with a passphrase. The private key is ~/.ssh/id_ed25519 and must never be shared. The public key is ~/.ssh/id_ed25519.pub

Add it to your SSH agent so that you type the passphrase once per session instead of on every signature

On Windows, the agent service is turned off by default. Turn it on once from a PowerShell window opened as administrator:

Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent

Then add the key from a normal PowerShell window:

ssh-add "$env:USERPROFILE\.ssh\id_ed25519"

On Ubuntu:

ssh-add ~/.ssh/id_ed25519

If ssh-add says it cannot connect to the agent, start one first with eval "$(ssh-agent -s)"

Configure jj

Add to jj config edit --user:

[signing]
behavior = "own"
backend = "ssh"
key = "~/.ssh/id_ed25519.pub"

[ui]
show-cryptographic-signatures = true
  • behavior = "own" signs every change you author whenever jj creates or rewrites it
  • show-cryptographic-signatures shows each commit's signature status in the log

Let jj verify signatures

Create an allowed_signers file that lists trusted keys

PowerShell:

$Email = "your-email@example.com"
$PublicKey = (Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub" -Raw).Trim()
[IO.File]::WriteAllText(
    "$env:USERPROFILE\.ssh\allowed_signers",
    "$Email $PublicKey`n",
    [Text.UTF8Encoding]::new($false)
)

Bash or Zsh:

printf '%s %s\n' "your-email@example.com" "$(cat ~/.ssh/id_ed25519.pub)" > ~/.ssh/allowed_signers

Point jj at it:

[signing.backends.ssh]
# Windows:
allowed-signers = "C:\\Users\\YourName\\.ssh\\allowed_signers"
# Ubuntu:
# allowed-signers = "~/.ssh/allowed_signers"

Sign or unsign existing changes with jj sign -r abc (jsign abc) and jj unsign -r abc (junsign abc)

Configure Git too

If you also commit with Git in the same repositories, configure Git separately in ~/.gitconfig:

[user]
    name = Your Name
    email = your-email@example.com
    signingkey = ~/.ssh/id_ed25519.pub

[commit]
    gpgsign = true

[gpg]
    format = ssh

On Windows, a forward-slash path such as C:/Users/YourName/.ssh/id_ed25519.pub avoids backslash escaping


19. Customize the log

The built-in templates are the fastest and most stable:

[templates]
log = "builtin_log_compact"                     # The default
# log = "builtin_log_compact_full_description"  # Show whole descriptions

Three-line log with signature status

This template shows IDs, bookmarks, and signature status on the first line, the description on the second, and the author and time on the third:

[template-aliases]
'format_sig(sig)' = '''
if(
  !sig,
  "○",
  if(sig.status() == "good", "✓", "!")
)
'''

[templates]
log = '''
if(
  root,
  format_root_commit(self),
  label(
    separate(
      " ",
      if(current_working_copy, "working_copy"),
      if(immutable, "immutable", "mutable"),
      if(conflict, "conflicted"),
    ),
    concat(
      separate(
        " ",
        format_short_change_id(change_id),
        format_short_commit_id(commit_id),
        format_sig(self.signature()),
        bookmarks,
        tags,
        working_copies,
      ) ++ "\n",
      separate(
        " ",
        if(empty, label("empty", "(empty)")),
        if(conflict, label("conflict", "conflict")),
        if(description, description.first_line(), description_placeholder),
      ) ++ "\n",
      "   " ++ format_timestamp(commit_timestamp(self))
            ++ " " ++ format_short_signature(author)
            ++ "\n",
    ),
  )
)
'''

Checking signatures can slow down large logs, because jj may call the signing tool for each commit. Switch back to the built-in template if that happens


20. Maintenance

Day-to-day work needs no cleanup. Old operations are kept on purpose so that jj undo and jj op restore keep working

Clean up forgotten changes

jclean          # Show the changes, then ask
jclean -y       # Show the changes and abandon them without asking

jclean selects only changes that are:

  • Mutable
  • Authored by you
  • Not below any bookmark, tag, remote bookmark, remote tag, or workspace

It shows them before abandoning. jj undo brings them back. Bash and Zsh also accept --yes

Remove leftover repository settings

jj config gc        # jcfgc

Removes stored per-repository settings for repositories that no longer exist

Shorten the operation history

Find the newest operation you want to discard in jop, then:

jopclean OPERATION_ID

It shows that operation and asks you to type abandon. It then runs jj op abandon ..OPERATION_ID, which discards that operation and everything older

Warning

Discarded operations can no longer be undone or restored, and the old commits they protected can be removed by garbage collection

Garbage collection

jj util gc                   # jgc
jj util gc --expire now      # jgc --expire now

Removes objects nothing refers to anymore. Normally only objects older than two weeks are removed. With --expire now, all of them are removed, including any made unreachable by jopclean


21. Shortcut reference

Every shortcut in the profile. [X] is optional. A revision you leave out means @

Pass-through and setup

Shortcut Runs
j ARGS jj ARGS
jb, jt, jg, jws, jsparse, jcfg, jutil jj bookmark, jj tag, jj git, jj workspace, jj sparse, jj config, jj util, followed by your arguments
ji jj git init --colocate
jclone URL jj git clone URL

Look around

Shortcut Runs
js jj status
jl [REVSET] jj log -r REVSET
jla jj log -r 'all()'
jll [N] jj log --limit N, 20 by default
jlb [REV] [N] jj log -r 'ancestors(REV)' --limit N, with defaults trunk() and 20
jlbroot [REV] jj log -r 'root()::REV' --reversed, with trunk() by default
jstack [REV] jj log -r 'trunk()..REV'
jreview [REV] jj show --reversed -r 'trunk()..REV'
jnow jj status, then jstack
jmine jj log -r 'mine() & mutable()'
jconf jj log -r 'conflicts()'
jwip, jstale jj log -r 'wip()', jj log -r 'stale()'
jdiv jj log -r 'divergent()'
jprivate jj log of the git.private-commits revset
jsh [REV] jj show REV
jd [REV], jd A B jj diff -r REV, jj diff --from A --to B
jds, jfiles Like jd, adding --stat or --summary
jevo [REV] jj evolog -r REV
jidiff A B jj interdiff --from A --to B

Create and edit

Shortcut Runs
jn [REV] jj new REV
jnm MESSAGE jj new -m "MESSAGE"
jm MESSAGE jj describe -m "MESSAGE"
jcm MESSAGE jj commit -m "MESSAGE"
jci, jdesc jj commit, jj describe, writing the message in your editor
jme jj metaedit
je REV jj edit REV
jep jj edit '@-'
jnext, jprev jj next, jj prev
jrestore REV [PATHS] jj restore --from REV [PATHS]
jdiscard [PATHS] jj restore [PATHS], discarding edits in @
jrevert REV jj revert -r REV --insert-before '@'
ja [REV] jj abandon REV

Reshape

Shortcut Runs
jsq [REV] jj squash -r REV
jsqi TARGET [PATHS] jj squash --into TARGET [PATHS]
jsqp [REV] jj squash --interactive -r REV
jsplit [REV] jj split -r REV
jabs [REV] jj absorb --from REV
jfix [REV] jj fix -s REV
jdupe [REV] jj duplicate REV, then jj edit the copy
jrb DEST jj rebase --onto DEST
jrbs SOURCE DEST jj rebase --source SOURCE --onto DEST
jrbr REVS DEST jj rebase --revisions REVS --onto DEST
jarr jj arrange
jpar REVSET jj parallelize REVSET
jsimp [REV] jj simplify-parents -r REV
jdiffedit [REV] jj diffedit -r REV
jconv jj converge
jrun COMMAND jj run -r '@' -- COMMAND
jrunstack COMMAND jj run -r 'trunk()..@' -- COMMAND
jbis jj bisect

Conflicts

Shortcut Runs
jres [PATHS] jj resolve [PATHS]
jresl jj resolve --list

Bookmarks

Shortcut Runs
jbl jj bookmark list
jbc NAME [REV] jj bookmark create NAME -r REV
jbs NAME [REV] jj bookmark set NAME -r REV
jbm NAME [REV] jj bookmark move NAME --to REV --allow-backwards
jba jj bookmark advance
jbd NAME jj bookmark delete NAME
jbdp NAME [REMOTE] Delete locally, then push the deletion
jbr OLD NEW jj bookmark rename OLD NEW
jbt NAME@REMOTE, jbu NAME@REMOTE jj bookmark track, jj bookmark untrack

Fetch, push, and publish

Shortcut Runs
jgf [REMOTE] jj git fetch --remote REMOTE
jgfa jj git fetch --all-remotes
jup [REMOTE] Fetch, then jj rebase --onto 'trunk()'
jgp [NAME] jj git push --bookmark NAME, or jj git push without a name
jgpp NAME [REMOTE] jj git push --bookmark NAME --dry-run
jpub NAME [REV] [REMOTE] jj bookmark set NAME -r REV, then push it
jpc [REV] [REMOTE] jj git push --change REV
jgpa jj git push --all: Every bookmark and tag
jgpforce NAME [REMOTE] Unconditional force-push of one bookmark
jgra, jgrr, jgrl, jgrn, jgru jj git remote add, remove, list, rename, set-url
jgroot jj git root

Tags

Shortcut Runs
jtl, jtla jj tag list, adding --all-remotes for jtla
jtc NAME [REV] jj tag set NAME -r REV
jtm NAME [REV] jj tag set NAME -r REV --allow-move
jtd NAME jj tag delete NAME
jtp NAME [REMOTE] jj git push --tag NAME
jtsp NAME [REV] [REMOTE] Create the tag, then push it
jtdp NAME [REMOTE] Delete the tag, then push the deletion
jtrl [REMOTE] List the remote's tags without fetching
jtagmirror [REMOTE] Preview every tag change, then push after you type yes

Undo and operations

Shortcut Runs
jundo, jredo jj undo, jj redo
jop jj op log
jopshow OP, jopdiff jj op show OP, jj op diff
joprevert OP jj op revert OP
joprestore OP jj op restore OP
jopclean OP jj op abandon ..OP after you type abandon

Files

Shortcut Runs
jfshow PATH jj file show PATH
jflist [REV] jj file list -r REV
jfa PATH jj file annotate PATH
jftrack, jfuntrack jj file track, jj file untrack
jfchmod x PATH jj file chmod x PATH. Use n instead of x to remove the executable bit

Utilities

Shortcut Runs
jsign [REV], junsign [REV] jj sign -r REV, jj unsign -r REV
jcfgc jj config gc
jgc jj util gc
jclean [-y] Abandon your disconnected changes after showing them
jroot, jcd, jcode Print the repository root, go to it, or open it in VS Code if you have it
jver, jh jj version, jj help

Appendix A: PowerShell profile

Paste this into $PROFILE below the completion lines from Set up your shell

# Pass-through and repository setup
function j       { jj @args }
function ji      { jj git init --colocate @args }
function jclone  { jj git clone @args }

# Argument handling shared by the shortcuts: leading plain values fill a shortcut's
# slots, flags go to jj unchanged, and defaults apply only when you did not type the
# matching flag yourself.
function _jjIsFlag { param($Value) "$Value".StartsWith('-') }
function _jjHasFlag {
    param([object[]]$Arguments, [string[]]$Flags)
    foreach ($Argument in $Arguments) {
        if ($Flags -ccontains ("$Argument" -replace '=.*$', '')) { return $true }
    }
    $false
}
function _jjRev {
    # jj COMMAND, where a leading plain value becomes "FLAG value".
    param([string[]]$Command, [string]$Flag, [object[]]$Arguments)
    $JJArgs = @($Command)
    $Rest = @($Arguments)
    if ($Rest.Count -gt 0 -and -not (_jjIsFlag $Rest[0])) {
        $JJArgs += $Flag, $Rest[0]
        $Rest = @($Rest | Select-Object -Skip 1)
    }
    jj @JJArgs @Rest
}
function _jjRange {
    # Like _jjRev, but two leading plain values become "--from A --to B".
    param([string[]]$Command, [string]$Flag, [object[]]$Arguments)
    $Rest = @($Arguments)
    if ($Rest.Count -ge 2 -and -not (_jjIsFlag $Rest[0]) -and -not (_jjIsFlag $Rest[1])) {
        $From, $To = $Rest[0], $Rest[1]
        $Rest = @($Rest | Select-Object -Skip 2)
        jj @Command --from $From --to $To @Rest
        return
    }
    _jjRev $Command $Flag $Rest
}
function _jjNamed {
    # jj COMMAND NAME, where a second plain value becomes "FLAG value".
    param([string[]]$Command, [string]$Flag, [object[]]$Arguments)
    $JJArgs = @($Command)
    $Rest = @($Arguments)
    if ($Rest.Count -gt 0 -and -not (_jjIsFlag $Rest[0])) {
        $JJArgs += $Rest[0]
        $Rest = @($Rest | Select-Object -Skip 1)
        if ($Rest.Count -gt 0 -and -not (_jjIsFlag $Rest[0])) {
            $JJArgs += $Flag, $Rest[0]
            $Rest = @($Rest | Select-Object -Skip 1)
        }
    }
    jj @JJArgs @Rest
}
function _jjStack {
    # jj COMMAND for trunk()..REVISION, where a leading plain value replaces @.
    param([string[]]$Command, [object[]]$Arguments)
    $Rest = @($Arguments)
    $Revision = '@'
    if ($Rest.Count -gt 0 -and -not (_jjIsFlag $Rest[0])) {
        $Revision = $Rest[0]
        $Rest = @($Rest | Select-Object -Skip 1)
    }
    jj @Command -r "trunk()..($Revision)" @Rest
}
function _jjRebaseOnto {
    # jj rebase FLAG FIRST --onto SECOND. A leading flag passes everything through.
    param([string]$Flag, [string]$Usage, [object[]]$Arguments)
    $Rest = @($Arguments)
    if ($Rest.Count -gt 0 -and (_jjIsFlag $Rest[0])) {
        jj rebase @Rest
        return
    }
    if ($Rest.Count -lt 2 -or (_jjIsFlag $Rest[1])) {
        Write-Error $Usage
        return
    }
    $Tail = @($Rest | Select-Object -Skip 2)
    jj rebase $Flag $Rest[0] --onto $Rest[1] @Tail
}

# Status, logs, and diffs
function js      { jj status @args }
function jl      { _jjRev log '-r' $args }
function jla     { jj log -r "all()" @args }
function jll {
    param([int]$Limit = 20)
    jj log --limit $Limit @args
}
function jlb {
    param($Bookmark = "trunk()", [int]$Limit = 20)
    jj log -r "ancestors($Bookmark)" --limit $Limit @args
}
function jlbroot {
    param($Bookmark = "trunk()")
    jj log -r "root()::$Bookmark" --reversed @args
}
function jstack  { _jjStack log $args }
function jreview { _jjStack @('show', '--reversed') $args }
function jnow {
    jj status
    if ($LASTEXITCODE -eq 0) {
        jstack
    }
}
function jmine   { jj log -r "mine() & mutable()" @args }
function jconf   { jj log -r "conflicts()" @args }
function jwip    { jj log -r "wip()" @args }
function jstale  { jj log -r "stale()" @args }
function jdiv    { jj log -r "divergent()" @args }
function jprivate {
    $PrivateRevset = jj config get git.private-commits
    if ($LASTEXITCODE -eq 0) {
        jj log -r $PrivateRevset @args
    }
}
function jsh     { jj show @args }
function jd      { _jjRange diff '-r' $args }
function jds     { _jjRange @('diff', '--stat') '-r' $args }
function jfiles  { _jjRange @('diff', '--summary') '-r' $args }
function jevo    { _jjRev evolog '-r' $args }
function jidiff  { _jjRange interdiff '--from' $args }

# Create, edit, and restore changes
function jn        { jj new @args }
function jnm {
    if ($args.Count -eq 0) {
        Write-Error "Usage: jnm <message>"
        return
    }
    jj new -m ($args -join " ")
}
function jm {
    if ($args.Count -eq 0) {
        Write-Error "Usage: jm <message>"
        return
    }
    jj describe -m ($args -join " ")
}
function jcm {
    if ($args.Count -eq 0) {
        Write-Error "Usage: jcm <message>"
        return
    }
    jj commit -m ($args -join " ")
}
function jci       { jj commit @args }
function jdesc     { jj describe @args }
function jme       { jj metaedit @args }
function je        { jj edit @args }
function jep       { jj edit "@-" @args }
function jnext     { jj next @args }
function jprev     { jj prev @args }
function jrestore {
    if ($args.Count -eq 0) {
        Write-Error "Usage: jrestore <revision> [paths]. To discard the changes in @, use: jdiscard [paths]"
        return
    }
    _jjRev restore '--from' $args
}
function jdiscard  { jj restore @args }
function jrevert {
    if ($args.Count -eq 0) {
        Write-Error "Usage: jrevert <revision> [flags]"
        return
    }
    $Placement = @()
    if (-not (_jjHasFlag $args '-o', '--onto', '-d', '--destination', '-A', '--insert-after', '--after', '-B', '--insert-before', '--before')) {
        $Placement = '--insert-before', '@'
    }
    _jjRev revert '-r' (@($args) + $Placement)
}
function ja        { jj abandon @args }

# Shape history
function jsq       { _jjRev squash '-r' $args }
function jsqi {
    if ($args.Count -eq 0) {
        Write-Error "Usage: jsqi <target-revision> [paths]"
        return
    }
    _jjRev squash '--into' $args
}
function jsqp      { _jjRev @('squash', '--interactive') '-r' $args }
function jsplit    { _jjRev split '-r' $args }
function jabs      { _jjRev absorb '--from' $args }
function jfix      { _jjRev fix '-s' $args }
function jdupe {
    # Duplicate (default @), then edit the copy when exactly one was made.
    $Output = @(jj duplicate --color=never @args 2>&1 | ForEach-Object { "$_" })
    $ExitCode = $LASTEXITCODE
    $Output | ForEach-Object { Write-Host $_ }
    if ($ExitCode -ne 0) { return }

    $Copies = @()
    foreach ($Line in $Output) {
        if ($Line -match '^Duplicated \S+ as (\S+) ') { $Copies += $Matches[1] }
    }
    if ($Copies.Count -eq 1) {
        jj edit $Copies[0]
    }
    elseif ($Copies.Count -gt 1) {
        Write-Host "Made $($Copies.Count) copies and stayed on the current change. Edit one with: je CHANGE_ID"
    }
    else {
        Write-Error "Could not find the new change in the output above. Edit it with: je CHANGE_ID"
    }
}
function jrb {
    if ($args.Count -eq 0) {
        Write-Error "Usage: jrb <destination> [flags]"
        return
    }
    _jjRev rebase '--onto' $args
}
function jrbs      { _jjRebaseOnto '--source' 'Usage: jrbs <source> <destination> [flags]' $args }
function jrbr      { _jjRebaseOnto '--revisions' 'Usage: jrbr <revisions> <destination> [flags]' $args }
function jarr      { jj arrange @args }
function jpar      { jj parallelize @args }
function jsimp     { _jjRev simplify-parents '-r' $args }
function jdiffedit { _jjRev diffedit '-r' $args }
function jconv     { jj converge @args }
function jrun {
    # Leading flags are jj run options, and the first plain word starts the command.
    $Options = @()
    $Index = 0
    while ($Index -lt $args.Count -and (_jjIsFlag $args[$Index])) {
        if ($args[$Index] -ceq '--') { $Index++; break }
        $Options += $args[$Index]
        if ($args[$Index] -cin '-r', '--revision', '-j', '--jobs', '-R', '--repository') {
            $Index++
            if ($Index -lt $args.Count) { $Options += $args[$Index] }
        }
        $Index++
    }
    $Command = @($args | Select-Object -Skip $Index)
    if ($Command.Count -eq 0) {
        Write-Error "Usage: jrun [-r REVSET] [-j JOBS] <command> [arguments]"
        return
    }
    # Plain `jj run` covers every mutable revision connected to @, so default to @ alone.
    if (-not (_jjHasFlag $Options '-r', '--revision')) {
        $Options = @('-r', '@') + $Options
    }
    jj run @Options -- @Command
}
function jrunstack { jrun -r "trunk()..@" @args }
function jbis      { jj bisect @args }

# Conflicts
function jres      { jj resolve @args }
function jresl     { jj resolve --list @args }

# Bookmarks
function jb        { jj bookmark @args }
function jbl       { jj bookmark list @args }
function jbc       { _jjNamed @('bookmark', 'create') '-r' $args }
function jbs       { _jjNamed @('bookmark', 'set') '-r' $args }
function jbm       { _jjNamed @('bookmark', 'move', '--allow-backwards') '--to' $args }
function jba       { jj bookmark advance @args }
function jbd       { jj bookmark delete @args }
function jbr       { jj bookmark rename @args }
function jbt       { jj bookmark track @args }
function jbu       { jj bookmark untrack @args }

# Remote helpers: use the only configured remote unless one is named
function _jjGit {
    $GitRoot = jj git root
    if ($LASTEXITCODE -eq 0) {
        git -C $GitRoot @args
    }
}
function _jjResolveRemote {
    param($Remote)
    if ($Remote) { return $Remote }

    $Remotes = @(
        @(_jjGit remote) |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
    )
    if ($LASTEXITCODE -ne 0) { return }

    if ($Remotes.Count -eq 1) { return $Remotes[0] }
    if ($Remotes.Count -eq 0) {
        Write-Error "This repository has no Git remote."
        return
    }

    Write-Error "This repository has multiple Git remotes. Pass the remote name explicitly."
}
function _jjLatest {
    # @, or @- when @ is an empty change with no description (as right after jcm).
    $Latest = jj log -r '@' --no-graph -T 'if(empty && description == "", "@-", "@")'
    if ($LASTEXITCODE -ne 0) { return }
    if ($Latest -eq '@-') {
        Write-Host "Using @- because @ is empty and has no description."
    }
    $Latest
}
function jbdp {
    param($Name, $Remote)
    if (-not $Name) {
        Write-Error "Usage: jbdp <bookmark> [remote]"
        return
    }
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }

    jj bookmark delete "exact:$Name"
    if ($LASTEXITCODE -eq 0) {
        jj git push --bookmark "exact:$Name" --remote $ResolvedRemote @args
    }
}

# Fetch, push, and publish
function jg        { jj git @args }
function jgf       { _jjRev @('git', 'fetch') '--remote' $args }
function jgfa      { jj git fetch --all-remotes @args }
function jup {
    jgf @args
    if ($LASTEXITCODE -eq 0) {
        jj rebase --onto "trunk()"
    }
}
function jgp       { _jjRev @('git', 'push') '--bookmark' $args }
function jgpp {
    param($Bookmark, $Remote)
    if (-not $Bookmark) {
        Write-Error "Usage: jgpp <bookmark> [remote]"
        return
    }
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }
    jj git push --bookmark "exact:$Bookmark" --remote $ResolvedRemote --dry-run
}
function jpub {
    param($Bookmark, $Revision, $Remote)
    if (-not $Bookmark) {
        Write-Error "Usage: jpub <bookmark> [revision] [remote]"
        return
    }
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }
    if (-not $Revision) { $Revision = _jjLatest }
    if (-not $Revision) { return }

    jj bookmark set $Bookmark --revision $Revision
    if ($LASTEXITCODE -eq 0) {
        jj git push --bookmark "exact:$Bookmark" --remote $ResolvedRemote
    }
}
function jpc {
    param($Revision, $Remote)
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }
    if (-not $Revision) { $Revision = _jjLatest }
    if (-not $Revision) { return }
    jj git push --change $Revision --remote $ResolvedRemote
}
function jgpa      { jj git push --all @args }
function jgpforce {
    param($Bookmark, $Remote)
    if (-not $Bookmark) {
        Write-Error "Usage: jgpforce <bookmark> [remote]"
        return
    }
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }

    jj git export
    if ($LASTEXITCODE -ne 0) { return }

    _jjGit push --force $ResolvedRemote "refs/heads/${Bookmark}:refs/heads/${Bookmark}"
    if ($LASTEXITCODE -eq 0) {
        jj git fetch --remote $ResolvedRemote
    }
}

# Git remotes
function jgra      { jj git remote add @args }
function jgrr      { jj git remote remove @args }
function jgrl      { jj git remote list @args }
function jgrn      { jj git remote rename @args }
function jgru      { jj git remote set-url @args }
function jgroot    { jj git root @args }

# Tags
function jt        { jj tag @args }
function jtl       { jj tag list @args }
function jtla      { jj tag list --all-remotes @args }
function jtc       { _jjNamed @('tag', 'set') '-r' $args }
function jtm       { _jjNamed @('tag', 'set', '--allow-move') '-r' $args }
function jtd       { jj tag delete @args }
function jtp {
    param($Name, $Remote)
    if (-not $Name) {
        Write-Error "Usage: jtp <tag> [remote]"
        return
    }
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }
    jj git push --tag "exact:$Name" --remote $ResolvedRemote @args
}
function jtsp {
    param($Name, $Revision, $Remote)
    if (-not $Name) {
        Write-Error "Usage: jtsp <tag> [revision] [remote]"
        return
    }
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }
    if (-not $Revision) { $Revision = _jjLatest }
    if (-not $Revision) { return }

    jj tag set $Name --revision $Revision
    if ($LASTEXITCODE -eq 0) {
        jtp $Name $ResolvedRemote @args
    }
}
function jtdp {
    param($Name, $Remote)
    if (-not $Name) {
        Write-Error "Usage: jtdp <tag> [remote]"
        return
    }
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }

    jj tag delete "exact:$Name"
    if ($LASTEXITCODE -eq 0) {
        jj git push --tag "exact:$Name" --remote $ResolvedRemote @args
    }
}
function jtrl {
    param($Remote)
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }
    _jjGit ls-remote --tags --refs $ResolvedRemote @args
}
function jtagmirror {
    param($Remote)
    $ResolvedRemote = _jjResolveRemote $Remote
    if (-not $ResolvedRemote) { return }

    jj git push --tag '*' --remote $ResolvedRemote --dry-run
    if ($LASTEXITCODE -ne 0) { return }

    $Confirm = Read-Host "Type yes to push these tag changes to '$ResolvedRemote'"
    if ($Confirm -cne "yes") {
        Write-Host "Cancelled."
        return
    }
    jj git push --tag '*' --remote $ResolvedRemote
}

# Undo and operations
function jundo      { jj undo @args }
function jredo      { jj redo @args }
function jop        { jj op log @args }
function jopshow    { jj op show @args }
function jopdiff    { jj op diff @args }
function joprevert  { jj op revert @args }
function joprestore { jj op restore @args }
function jopclean {
    param($Operation)
    if (-not $Operation -or $args.Count -gt 0) {
        Write-Error "Usage: jopclean <newest-operation-to-abandon>"
        return
    }

    jj op show $Operation
    if ($LASTEXITCODE -ne 0) { return }

    Write-Host "`nThis will abandon operation '$Operation' and every ancestor." -ForegroundColor Yellow
    Write-Host "Those states stop protecting old commits from garbage collection." -ForegroundColor Yellow
    $Confirm = Read-Host "Type abandon to continue"
    if ($Confirm -cne "abandon") {
        Write-Host "Cancelled."
        return
    }
    jj op abandon "..$Operation"
}

# Workspaces, sparse patterns, and files
function jws       { jj workspace @args }
function jsparse   { jj sparse @args }
function jfshow    { jj file show @args }
function jflist    { _jjRev @('file', 'list') '-r' $args }
function jfa       { jj file annotate @args }
function jftrack   { jj file track @args }
function jfuntrack { jj file untrack @args }
function jfchmod   { jj file chmod @args }

# Signing, configuration, and utilities
function jsign     { _jjRev sign '-r' $args }
function junsign   { _jjRev unsign '-r' $args }
function jcfg      { jj config @args }
function jcfgc     { jj config gc @args }
function jgc       { jj util gc @args }
function jutil     { jj util @args }
function jclean {
    param([Alias("y")][switch]$Yes)

    $Revs = '(mine() & mutable()) ~ ::(bookmarks() | remote_bookmarks() | tags() | remote_tags() | working_copies())'
    $Ids = @(jj log -r $Revs --no-graph -T 'commit_id ++ "\n"')
    if ($LASTEXITCODE -ne 0) { return }

    if ($Ids.Count -eq 0) {
        Write-Host "No disconnected mutable changes authored by you."
        return
    }

    jj log -r $Revs
    Write-Host "`n$($Ids.Count) disconnected mutable change(s) found." -ForegroundColor Yellow
    if ($Yes) {
        jj abandon -r $Revs
        return
    }

    $Confirm = Read-Host "Abandon these changes? [y/N]"
    if ($Confirm -notmatch '^(y|yes)$') {
        Write-Host "Cancelled."
        return
    }
    jj abandon -r $Revs
}
function jroot     { jj root @args }
function jcd {
    $Root = jj root
    if ($LASTEXITCODE -eq 0) {
        Set-Location -LiteralPath $Root
    }
}
function jcode {
    $Root = jj root
    if ($LASTEXITCODE -eq 0) {
        code.cmd $Root @args
    }
}
function jver      { jj version @args }
function jh        { jj help @args }

Appendix B: Bash and Zsh profile

Paste this into ~/.bashrc or ~/.zshrc, below the completion line from Set up your shell

# Pass-through and repository setup
j()      { jj "$@"; }
ji()     { jj git init --colocate "$@"; }
jclone() { jj git clone "$@"; }

# Argument handling shared by the shortcuts: leading plain values fill a shortcut's
# slots, flags go to jj unchanged, and defaults apply only when you did not type the
# matching flag yourself.
_jj_has_flag() {
    # _jj_has_flag "FLAG..." ARGUMENTS...: succeed when an argument is one of the flags.
    local flags=" $1 " argument
    shift
    for argument in "$@"; do
        [[ "$flags" == *" ${argument%%=*} "* ]] && return 0
    done
    return 1
}
_jj_rev() {
    # _jj_rev FLAG COMMAND... -- ARGUMENTS...: a leading plain value becomes FLAG VALUE.
    local flag="$1"
    shift
    local -a jj_args=()
    while [[ $# -gt 0 && "$1" != "--" ]]; do
        jj_args+=("$1")
        shift
    done
    [[ $# -gt 0 ]] && shift
    if [[ $# -gt 0 && "$1" != -* ]]; then
        jj_args+=("$flag" "$1")
        shift
    fi
    jj "${jj_args[@]}" "$@"
}
_jj_range() {
    # Like _jj_rev, but two leading plain values become --from A --to B.
    local flag="$1"
    shift
    local -a jj_args=()
    while [[ $# -gt 0 && "$1" != "--" ]]; do
        jj_args+=("$1")
        shift
    done
    [[ $# -gt 0 ]] && shift
    if [[ $# -ge 2 && "$1" != -* && "$2" != -* ]]; then
        jj_args+=(--from "$1" --to "$2")
        shift 2
    elif [[ $# -gt 0 && "$1" != -* ]]; then
        jj_args+=("$flag" "$1")
        shift
    fi
    jj "${jj_args[@]}" "$@"
}
_jj_named() {
    # _jj_named FLAG COMMAND... -- ARGUMENTS...: NAME, then a plain value becomes FLAG VALUE.
    local flag="$1"
    shift
    local -a jj_args=()
    while [[ $# -gt 0 && "$1" != "--" ]]; do
        jj_args+=("$1")
        shift
    done
    [[ $# -gt 0 ]] && shift
    if [[ $# -gt 0 && "$1" != -* ]]; then
        jj_args+=("$1")
        shift
        if [[ $# -gt 0 && "$1" != -* ]]; then
            jj_args+=("$flag" "$1")
            shift
        fi
    fi
    jj "${jj_args[@]}" "$@"
}
_jj_stack() {
    # _jj_stack COMMAND... -- ARGUMENTS...: trunk()..REVISION, where a plain value replaces @.
    local -a jj_args=()
    while [[ $# -gt 0 && "$1" != "--" ]]; do
        jj_args+=("$1")
        shift
    done
    [[ $# -gt 0 ]] && shift
    local revision=@
    if [[ $# -gt 0 && "$1" != -* ]]; then
        revision="$1"
        shift
    fi
    jj "${jj_args[@]}" -r "trunk()..($revision)" "$@"
}
_jj_rebase_onto() {
    # _jj_rebase_onto FLAG USAGE ARGUMENTS...: jj rebase FLAG FIRST --onto SECOND.
    local flag="$1" usage="$2"
    shift 2
    if [[ $# -gt 0 && "$1" == -* ]]; then
        jj rebase "$@"
        return
    fi
    if [[ $# -lt 2 || "$2" == -* ]]; then
        printf '%s\n' "$usage" >&2
        return 2
    fi
    local first="$1" destination="$2"
    shift 2
    jj rebase "$flag" "$first" --onto "$destination" "$@"
}

# Status, logs, and diffs
js()  { jj status "$@"; }
jl()  { _jj_rev -r log -- "$@"; }
jla() { jj log -r "all()" "$@"; }
jll() {
    local limit="${1:-20}"
    shift || true
    jj log --limit "$limit" "$@"
}
jlb() {
    local bookmark="${1:-trunk()}"
    shift || true
    local limit="${1:-20}"
    shift || true
    jj log -r "ancestors($bookmark)" --limit "$limit" "$@"
}
jlbroot() {
    local bookmark="${1:-trunk()}"
    shift || true
    jj log -r "root()::$bookmark" --reversed "$@"
}
jstack()  { _jj_stack log -- "$@"; }
jreview() { _jj_stack show --reversed -- "$@"; }
jnow()    { jj status && jstack; }
jmine()   { jj log -r "mine() & mutable()" "$@"; }
jconf()   { jj log -r "conflicts()" "$@"; }
jwip()    { jj log -r "wip()" "$@"; }
jstale()  { jj log -r "stale()" "$@"; }
jdiv()    { jj log -r "divergent()" "$@"; }
jprivate() {
    local private_revset
    private_revset=$(jj config get git.private-commits) || return
    jj log -r "$private_revset" "$@"
}
jsh()     { jj show "$@"; }
jd()      { _jj_range -r diff -- "$@"; }
jds()     { _jj_range -r diff --stat -- "$@"; }
jfiles()  { _jj_range -r diff --summary -- "$@"; }
jevo()    { _jj_rev -r evolog -- "$@"; }
jidiff()  { _jj_range --from interdiff -- "$@"; }

# Create, edit, and restore changes
jn() { jj new "$@"; }
jnm() {
    if [[ $# -eq 0 ]]; then
        printf 'Usage: jnm MESSAGE\n' >&2
        return 2
    fi
    jj new -m "$*"
}
jm() {
    if [[ $# -eq 0 ]]; then
        printf 'Usage: jm MESSAGE\n' >&2
        return 2
    fi
    jj describe -m "$*"
}
jcm() {
    if [[ $# -eq 0 ]]; then
        printf 'Usage: jcm MESSAGE\n' >&2
        return 2
    fi
    jj commit -m "$*"
}
jci()      { jj commit "$@"; }
jdesc()    { jj describe "$@"; }
jme()      { jj metaedit "$@"; }
je()       { jj edit "$@"; }
jep()      { jj edit "@-" "$@"; }
jnext()    { jj next "$@"; }
jprev()    { jj prev "$@"; }
jrestore() {
    if [[ $# -eq 0 ]]; then
        printf 'Usage: jrestore REVISION [PATHS]. To discard the changes in @, use: jdiscard [PATHS]\n' >&2
        return 2
    fi
    _jj_rev --from restore -- "$@"
}
jdiscard() { jj restore "$@"; }
jrevert() {
    if [[ $# -eq 0 ]]; then
        printf 'Usage: jrevert REVISION [FLAGS]\n' >&2
        return 2
    fi
    if _jj_has_flag "-o --onto -d --destination -A --insert-after --after -B --insert-before --before" "$@"; then
        _jj_rev -r revert -- "$@"
    else
        _jj_rev -r revert -- "$@" --insert-before @
    fi
}
ja()       { jj abandon "$@"; }

# Shape history
jsq() { _jj_rev -r squash -- "$@"; }
jsqi() {
    if [[ $# -eq 0 ]]; then
        printf 'Usage: jsqi TARGET_REVISION [PATHS]\n' >&2
        return 2
    fi
    _jj_rev --into squash -- "$@"
}
jsqp()   { _jj_rev -r squash --interactive -- "$@"; }
jsplit() { _jj_rev -r split -- "$@"; }
jabs()   { _jj_rev --from absorb -- "$@"; }
jfix()   { _jj_rev -s fix -- "$@"; }
jdupe() {
    # Duplicate (default @), then edit the copy when exactly one was made.
    local output exit_code copies count
    output=$(jj duplicate --color=never "$@" 2>&1)
    exit_code=$?
    [[ -n "$output" ]] && printf '%s\n' "$output"
    [[ $exit_code -eq 0 ]] || return "$exit_code"

    copies=$(printf '%s\n' "$output" | sed -nE 's/^Duplicated [^ ]+ as ([^ ]+) .*/\1/p')
    count=$(printf '%s\n' "$copies" | awk 'NF { n++ } END { print n + 0 }')
    case "$count" in
        1) jj edit "$copies" ;;
        0)
            printf 'Could not find the new change in the output above. Edit it with: je CHANGE_ID\n' >&2
            return 1
            ;;
        *) printf 'Made %s copies and stayed on the current change. Edit one with: je CHANGE_ID\n' "$count" ;;
    esac
}
jrb() {
    if [[ $# -eq 0 ]]; then
        printf 'Usage: jrb DESTINATION [FLAGS]\n' >&2
        return 2
    fi
    _jj_rev --onto rebase -- "$@"
}
jrbs()      { _jj_rebase_onto --source 'Usage: jrbs SOURCE DESTINATION [FLAGS]' "$@"; }
jrbr()      { _jj_rebase_onto --revisions 'Usage: jrbr REVISIONS DESTINATION [FLAGS]' "$@"; }
jarr()      { jj arrange "$@"; }
jpar()      { jj parallelize "$@"; }
jsimp()     { _jj_rev -r simplify-parents -- "$@"; }
jdiffedit() { _jj_rev -r diffedit -- "$@"; }
jconv()     { jj converge "$@"; }
jrun() {
    # Leading flags are jj run options, and the first plain word starts the command.
    local -a run_options=()
    while [[ $# -gt 0 && "$1" == -* ]]; do
        if [[ "$1" == "--" ]]; then
            shift
            break
        fi
        run_options+=("$1")
        case "$1" in
            -r|--revision|-j|--jobs|-R|--repository)
                shift
                [[ $# -gt 0 ]] && run_options+=("$1")
                ;;
        esac
        [[ $# -gt 0 ]] && shift
    done
    if [[ $# -eq 0 ]]; then
        printf 'Usage: jrun [-r REVSET] [-j JOBS] COMMAND [ARGUMENTS]\n' >&2
        return 2
    fi
    # Plain `jj run` covers every mutable revision connected to @, so default to @ alone.
    if ! _jj_has_flag "-r --revision" "${run_options[@]}"; then
        run_options=(-r @ "${run_options[@]}")
    fi
    jj run "${run_options[@]}" -- "$@"
}
jrunstack() { jrun -r "trunk()..@" "$@"; }
jbis()      { jj bisect "$@"; }

# Conflicts
jres()  { jj resolve "$@"; }
jresl() { jj resolve --list "$@"; }

# Bookmarks
jb()  { jj bookmark "$@"; }
jbl() { jj bookmark list "$@"; }
jbc() { _jj_named -r bookmark create -- "$@"; }
jbs() { _jj_named -r bookmark set -- "$@"; }
jbm() { _jj_named --to bookmark move --allow-backwards -- "$@"; }
jba() { jj bookmark advance "$@"; }
jbd() { jj bookmark delete "$@"; }
jbr() { jj bookmark rename "$@"; }
jbt() { jj bookmark track "$@"; }
jbu() { jj bookmark untrack "$@"; }

# Remote helpers: use the only configured remote unless one is named
_jj_git() {
    local git_root
    git_root=$(jj git root) || return
    git -C "$git_root" "$@"
}
_jj_resolve_remote() {
    local requested_remote="${1:-}"
    if [[ -n "$requested_remote" ]]; then
        printf '%s\n' "$requested_remote"
        return
    fi

    local remote_output
    remote_output=$(_jj_git remote) || return
    local remote remote_count=0 sole_remote=
    while IFS= read -r remote; do
        if [[ -n "$remote" ]]; then
            sole_remote="$remote"
            remote_count=$((remote_count + 1))
        fi
    done <<<"$remote_output"

    case "$remote_count" in
        1) printf '%s\n' "$sole_remote" ;;
        0)
            printf 'This repository has no Git remote.\n' >&2
            return 1
            ;;
        *)
            printf 'This repository has multiple Git remotes. Pass the remote name explicitly.\n' >&2
            return 1
            ;;
    esac
}
_jj_latest() {
    # Print @, or @- when @ is an empty change with no description (as right after jcm).
    local latest
    latest=$(jj log -r @ --no-graph -T 'if(empty && description == "", "@-", "@")') || return
    if [[ "$latest" == "@-" ]]; then
        printf 'Using @- because @ is empty and has no description.\n' >&2
    fi
    printf '%s\n' "$latest"
}
jbdp() {
    local name="${1:?Usage: jbdp NAME [REMOTE]}"
    shift
    local requested_remote="${1:-}"
    if [[ $# -gt 0 ]]; then shift; fi
    local remote
    remote=$(_jj_resolve_remote "$requested_remote") || return
    jj bookmark delete "exact:$name" &&
        jj git push --bookmark "exact:$name" --remote "$remote" "$@"
}

# Fetch, push, and publish
jg()   { jj git "$@"; }
jgf()  { _jj_rev --remote git fetch -- "$@"; }
jgfa() { jj git fetch --all-remotes "$@"; }
jup() {
    jgf "$@" &&
        jj rebase --onto "trunk()"
}
jgp()  { _jj_rev --bookmark git push -- "$@"; }
jgpp() {
    local bookmark="${1:?Usage: jgpp BOOKMARK [REMOTE]}"
    local remote
    remote=$(_jj_resolve_remote "${2:-}") || return
    jj git push --bookmark "exact:$bookmark" --remote "$remote" --dry-run
}
jpub() {
    local bookmark="${1:?Usage: jpub BOOKMARK [REVISION] [REMOTE]}"
    local revision="${2:-}"
    local remote
    remote=$(_jj_resolve_remote "${3:-}") || return
    if [[ -z "$revision" ]]; then
        revision=$(_jj_latest) || return
    fi
    jj bookmark set "$bookmark" --revision "$revision" &&
        jj git push --bookmark "exact:$bookmark" --remote "$remote"
}
jpc() {
    local revision="${1:-}"
    local remote
    remote=$(_jj_resolve_remote "${2:-}") || return
    if [[ -z "$revision" ]]; then
        revision=$(_jj_latest) || return
    fi
    jj git push --change "$revision" --remote "$remote"
}
jgpa() { jj git push --all "$@"; }
jgpforce() {
    local bookmark="${1:?Usage: jgpforce BOOKMARK [REMOTE]}"
    local remote
    remote=$(_jj_resolve_remote "${2:-}") || return
    jj git export &&
        _jj_git push --force "$remote" \
            "refs/heads/${bookmark}:refs/heads/${bookmark}" &&
        jj git fetch --remote "$remote"
}

# Git remotes
jgra()   { jj git remote add "$@"; }
jgrr()   { jj git remote remove "$@"; }
jgrl()   { jj git remote list "$@"; }
jgrn()   { jj git remote rename "$@"; }
jgru()   { jj git remote set-url "$@"; }
jgroot() { jj git root "$@"; }

# Tags
jt()   { jj tag "$@"; }
jtl()  { jj tag list "$@"; }
jtla() { jj tag list --all-remotes "$@"; }
jtc()  { _jj_named -r tag set -- "$@"; }
jtm()  { _jj_named -r tag set --allow-move -- "$@"; }
jtd()  { jj tag delete "$@"; }
jtp() {
    local name="${1:?Usage: jtp NAME [REMOTE]}"
    local remote
    remote=$(_jj_resolve_remote "${2:-}") || return
    jj git push --tag "exact:$name" --remote "$remote"
}
jtsp() {
    local name="${1:?Usage: jtsp NAME [REVISION] [REMOTE]}"
    local revision="${2:-}"
    local remote
    remote=$(_jj_resolve_remote "${3:-}") || return
    if [[ -z "$revision" ]]; then
        revision=$(_jj_latest) || return
    fi
    jj tag set "$name" --revision "$revision" &&
        jtp "$name" "$remote"
}
jtdp() {
    local name="${1:?Usage: jtdp NAME [REMOTE]}"
    local remote
    remote=$(_jj_resolve_remote "${2:-}") || return
    jj tag delete "exact:$name" &&
        jj git push --tag "exact:$name" --remote "$remote"
}
jtrl() {
    local remote
    remote=$(_jj_resolve_remote "${1:-}") || return
    _jj_git ls-remote --tags --refs "$remote"
}
jtagmirror() {
    local remote
    remote=$(_jj_resolve_remote "${1:-}") || return
    jj git push --tag '*' --remote "$remote" --dry-run || return

    printf "Type yes to push these tag changes to '%s': " "$remote"
    local confirm
    read -r confirm
    if [[ "$confirm" != "yes" ]]; then
        printf 'Cancelled.\n'
        return
    fi
    jj git push --tag '*' --remote "$remote"
}

# Undo and operations
jundo()      { jj undo "$@"; }
jredo()      { jj redo "$@"; }
jop()        { jj op log "$@"; }
jopshow()    { jj op show "$@"; }
jopdiff()    { jj op diff "$@"; }
joprevert()  { jj op revert "$@"; }
joprestore() { jj op restore "$@"; }
jopclean() {
    if [[ $# -ne 1 ]]; then
        printf 'Usage: jopclean NEWEST_OPERATION_TO_ABANDON\n' >&2
        return 2
    fi
    local operation="$1"
    jj op show "$operation" || return

    printf "\nThis will abandon operation '%s' and every ancestor.\n" "$operation"
    printf 'Those states stop protecting old commits from garbage collection.\n'
    printf 'Type abandon to continue: '
    local confirm
    read -r confirm
    if [[ "$confirm" != "abandon" ]]; then
        printf 'Cancelled.\n'
        return
    fi
    jj op abandon "..$operation"
}

# Workspaces, sparse patterns, and files
jws()       { jj workspace "$@"; }
jsparse()   { jj sparse "$@"; }
jfshow()    { jj file show "$@"; }
jflist()    { _jj_rev -r file list -- "$@"; }
jfa()       { jj file annotate "$@"; }
jftrack()   { jj file track "$@"; }
jfuntrack() { jj file untrack "$@"; }
jfchmod()   { jj file chmod "$@"; }

# Signing, configuration, and utilities
jsign()   { _jj_rev -r sign -- "$@"; }
junsign() { _jj_rev -r unsign -- "$@"; }
jcfg()    { jj config "$@"; }
jcfgc()   { jj config gc "$@"; }
jgc()     { jj util gc "$@"; }
jutil()   { jj util "$@"; }
jclean() {
    local assume_yes=false
    case "${1:-}" in
        -y|--yes)
            assume_yes=true
            shift
            ;;
    esac
    if [[ $# -ne 0 ]]; then
        printf 'Usage: jclean [-y|--yes]\n' >&2
        return 2
    fi

    local revs='(mine() & mutable()) ~ ::(bookmarks() | remote_bookmarks() | tags() | remote_tags() | working_copies())'
    local count
    count=$(jj log -r "$revs" --no-graph -T 'commit_id ++ "\n"' |
        awk 'NF { count++ } END { print count + 0 }') || return

    if [[ "$count" -eq 0 ]]; then
        printf 'No disconnected mutable changes authored by you.\n'
        return
    fi

    jj log -r "$revs" || return
    printf '\n%s disconnected mutable change(s) found.\n' "$count"
    if [[ "$assume_yes" == true ]]; then
        jj abandon -r "$revs"
        return
    fi

    printf 'Abandon these changes? [y/N] '
    local confirm
    read -r confirm
    case "$confirm" in
        y|Y|yes|YES) jj abandon -r "$revs" ;;
        *) printf 'Cancelled.\n' ;;
    esac
}
jroot() { jj root "$@"; }
jcd() {
    local root
    root=$(jj root) || return
    cd "$root" || return
}
jcode() {
    local root
    root=$(jj root) || return
    code "$root" "$@"
}
jver() { jj version "$@"; }
jh()   { jj help "$@"; }

Further reading

Comments are disabled for this gist.