First Week with Jujutsu VCS
I’ve spent my first week using Jujutsu↗ (jj), a Git-compatible version control system that takes a different approach to managing commits and branches. Here are my impressions and some configuration tips I’ve picked up along the way.
Contents
- Configuring Immutable Bookmarks
- Emacs Integration with Majutsu
- Rebasing Stacked Branches
- Cleaning Up After Rebases
- Magit and jj - Conflict Resolution
- Magit and jj - ’@’ Branch
- Overall Impressions
Configuring Immutable Bookmarks
One of the first things I changed was the default immutability settings. Jujutsu’s default configuration locks down remote bookmarks (which are “branches” in Git terms) as immutable, which prevents accidental modifications but can be overly restrictive.
I added this to ~/.config/jj/config.toml:
[revset-aliases]
"immutable_heads()" = "present(trunk()) | tags()"
This relaxes the immutability rules to only protect the trunk (the default
branch, typically main or master from the backing Git repo) and tags, giving
me more flexibility to work with feature branches that have been pushed to
remotes.
Emacs Integration with Majutsu
For Emacs integration, I’m using majutsu↗, which provides a Magit-like interface for Jujutsu. I configured it to start in a log buffer showing the branches I care about, with the ability to hit Enter to visit a commit or O to start a new commit at that point.
Here’s my configuration from my shared Emacs init↗:
(defun my-replace-cdrs-in-alist (old-el new-el alist)
"Replace cdr instances of OLD-EL with NEW-EL in ALIST."
(mapc #'(lambda (el)
(when (eq (cdr el) old-el)
(setcdr el new-el)))
(symbol-value alist)))
;; Set up majutsu
(with-eval-after-load "majutsu"
;; Replace pop-to-buffer with switch-to-buffer for same-window behavior
(my-replace-cdrs-in-alist 'pop-to-buffer 'switch-to-buffer
'majutsu-display-functions)
(setopt majutsu-default-display-function #'switch-to-buffer))
(defun my-majutsu-log ()
(interactive)
(majutsu-log (or (my-project-root) default-directory)))
;; Set up keybinds
(with-eval-after-load "project"
(keymap-set project-prefix-map "j" #'my-majutsu-log))
(keymap-global-set "C-x V j" #'my-majutsu-log)
The key customizations here:
- Same-window display: I replaced
pop-to-bufferwithswitch-to-bufferso that majutsu buffers open in the current window rather than splitting. This matches my preferred Magit workflow. - A project-aware log: The
my-majutsu-logfunction opens the log at the detected project root, which I bind to C-x V j in my global keymap, or j when visiting a project. It will also offer to initialize jj if I haven’t done so yet.
Rebasing Stacked Branches
Rebasing stacked branches onto an updated main branch is quite nice with Jujutsu:
jj git fetch
# Rebase all commits on feature-branch that aren't already in main
jj rebase -b feature-branch -o main
The -b flag specifies the branch to rebase, and -o specifies the new base.
Jujutsu handles the rebase intelligently, only moving commits that aren’t
already part of main, and automatically brings over multiple bookmarks that may
be on that branch.
I also have a script fragment that can update all tracked bookmarks on all Git submodules:
# excerpt - assume $d is current submodule
main_branch=$(git config -f "$topdir"/.gitmodules submodule.$d.branch || echo main)
jj bookmark track "${main_branch}" --remote=origin 2>/dev/null || true
jj git fetch
# Check for bookmark conflicts after fetch
if jj bookmark list -t 2>/dev/null | grep -q "conflict"; then
echo "Warning: Bookmark conflicts detected in $d"
echo "Run 'cd $d && jj bookmark list -t' to see details"
echo "Use 'jj bookmark track <bookmark> --remote=origin' to resolve"
fi
# Rebase all tracked bookmarks (except main) onto updated main
# Get list of tracked bookmarks, excluding the main branch and @origin suffixes
other_bookmarks=$(jj bookmark list -t 2>/dev/null | \
grep -v "^ " | \
awk '{print $1}' | \
sed 's/:$//' | \
grep -v "^${main_branch}\$" || true)
if [[ -n "$other_bookmarks" ]]; then
for bookmark in $other_bookmarks; do
# Skip if bookmark name is empty or contains @origin
[[ -z "$bookmark" || "$bookmark" == *"@"* ]] && continue
# Check if bookmark has commits not in main
if jj log -r "${main_branch}..${bookmark}" --limit 1 2>/dev/null | grep -q .; then
echo "Rebasing ${bookmark} onto ${main_branch}"
if ! jj rebase -b "${bookmark}" -o "${main_branch}" 2>&1; then
echo "Warning: Failed to rebase ${bookmark} - may have conflicts"
echo "Run 'cd $d && jj log -r \"conflict()\"' to see conflicted commits"
fi
fi
done
fi
Cleaning Up After Rebases
One minor annoyance: after rebasing, you may end up with orphaned bookmarks in
your jj log output that clutter the view. You’ll need to manually clean these
up with jj abandon to keep a tidy log. It can be a bit tedious, but so far I’m
finding that it’s worth the effort to maintain a clear picture of which stacks
haven’t been merged yet.
Magit and jj - Conflict Resolution
Here’s an important gotcha I discovered: if you’re in the middle of resolving a
Jujutsu merge and you run magit-status, Emacs can slow to a crawl. Magit will
show hundreds of conflicting symlinks (which is how jj represents conflicts in
the working directory), overwhelming the buffer.
The best practice is to stay in Jujutsu’s tooling once you start resolving conflicts there. Don’t switch to Magit mid-resolution.
This situation commonly arises when squash-merging PRs for a branch that is stacked on another. GitHub’s squash merge creates a new commit that doesn’t match the commits jj knows about, which can leave your local branch in a state where jj thinks it needs to merge.
When this happens, it can save significant time to rebase using a specific commit ID rather than a bookmark name:
# Find the commit ID of main after the squash merge
jj log -r <bookmark>
# Rebase your bookmark starting at that specific commit onto main
jj rebase -s <earliest-commit-id-of-bookmark> -o main
This explicitly tells jj which commit to move and where, avoiding the merge conflicts that arise from the squash merge mismatch. I do something similar with subset-rebases in Magit when using Git.
Magit and jj - ’@’ Branch
Using jj and then using Magit can cause Magit to be confused about what branch
it’s on. Unfortunately because of the existence of @ as a branch, which comes
before the typical name of the branch when showing a revision, Magit treats @
as the one that’s meaningful and doesn’t show the merge/push target for the
branch I actually care about. I haven’t yet found a good solution, other than
manually specifying where to push. Maybe Magit could be advised to ignore that
branch at some point.
Overall Impressions
After a week, I’m finding Jujutsu’s model fairly intuitive once you get past the initial learning curve, though I’m still using Git most of the time. The automatic tracking of the working copy as a commit, the powerful revset language, and the Git compatibility does make jj an interesting alternative to keep learning.