First Week with Jujutsu VCS

Published:
Keywords: emacs

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

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-buffer with switch-to-buffer so that majutsu buffers open in the current window rather than splitting. This matches my preferred Magit workflow.
  • A project-aware log: The my-majutsu-log function 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.