Using Jujutsu (jj) to teach a course

Logo of Jujutsu: a version control system

Introduction

I only had a couple days to create a course for high school students to learn UI programming.

Instead of creating the content I focused on breaking down existing content; jj helped tremendously.

I'll give you some context, and then walk you through the jj new -A, jj split and jj log commands. To go further with jj, I recommend the https://steveklabnik.github.io/jujutsu-tutorial/ tutorial.

Psst. Happy Independence Day!

Context

My style of teaching is to:

  • go straight to the "end": a real example of what they will learn that day
  • verbally explain the code in the example
  • ask the students to apply the explanation. For example, the last lesson was grid layouts, and I asked them to:
    • extend the 3x3 example grid to 4x4 grid
    • extend their 4x4 grid to an approximation of the 48 states in the continental US

The instructor-led course is at https://github.com/diskuv/2025a-1-tutorial-for-codespaces . The students are taught on the first day how to navigate to the day's lesson and how to run the code themselves. If you are looking at the code, that is content/TUTORIAL-GitHub-Codespaces.md .

jj new -A

A technical requirement is the students must see only the content for that day's lesson so they don't get overwhelmed.

With git, we could separate each lesson into its own branch. There are 15 lessons so there are 15 branches. When students "navigate" to a day's lesson, all they are doing is switching branches.

mainLesson1Lesson2Lesson...Lesson150 (TUTORIAL.md)1 (tasks.json)2 (instructor/src/)3-e0058164-4c651895-6b917436-aace058

The main branch is for common course content that is independent of the lessons. In my course:

  • content/TUTORIAL-GitHub-Codespaces.md teaches the Visual Studio Code task key strokes for how to build code and how to switch branches. That is the anchor point that students can always go back to.
  • .vscode/tasks.json is the Visual Studio Code tasks
  • instructor/src/ has the source code for some of the more complex tasks (serving web pages, etc.). Yep, those tasks were written as dk scripts so the students could run them on macOS, Windows or Linux.

Inevitably new common course content must appear on the main branch. Perhaps I want to add my teaching notes:

It is easy enough to add the commit to the main branch with git. However, we want the new commit --- on the diagram it is labelled "3 (NOTES.md)" --- to appear in all of the lessons:

mainLesson1Lesson2Lesson...Lesson150 (TUTORIAL.md)1 (tasks.json)2 (instructor/src/)3 (NOTES.md)4-76086ad5-90852a96-ab6895a7-9d020af

Now git becomes difficult: each of the 15 lesson branch has to be rebased.

With jj, the rebasing is automatic for new commits:

$ jj new -A main --ignore-immutable
Rebased 24 descendant commits
Working copy  (@) now at: ozmkplps 64994bbe (empty) (no description set)
Parent commit (@-)      : nlvxrowq fcb427cc main main@instruction | Update lesson tasks
Added 0 files, modified 2 files, removed 0 files
Command or OptionExplanation
jj newCreate a new commit
-A mainPlace the commit after the main commit, but before all of main's descendants. Speak 'git' instead? We added an detached commit to the main branch, and rebased all branches that were forked off the main branch on top of the new detached commit.
--ignore-immutableOrdinarily jj will not let us rebase branches that have already been pushed to git. However, only the instructor (you or I) will be making modifications so rebasing is okay.

and then after editing

$ jj bookmark set main -r '@'
Moved 1 bookmarks to lktrqpry 5db1ab18 main* | Added NOTES.md
Command or OptionExplanation
jj bookmark set mainCreate or set the bookmark main. Bookmarks are like git tags.

-r '@'

Use the revision we are currently editing.

Speak 'git' instead? Use the detached commit.

Why single quotes? Because PowerShell is like Perl where @ expands array references. It is safest to quote all @ arguments, just like you would quote $ if you did not want to expand scalar references like $PATH.

you do the push

$ jj git push --all
Changes to push to origin:
  Move sideways bookmark lesson-basic-mvvm from f7e10ffcad3c to 11743a05fb6d
  Move sideways bookmark lesson-basic-viewlocator from 51de7c6f14a8 to 62d217cde68d
  Move sideways bookmark lesson-command from 8bf7805caa75 to b554278098b5
  Move sideways bookmark lesson-controls from 917bf252b485 to 3f928d94eb1b
  Move sideways bookmark lesson-layouts from d05ba568f3ab to c99d1406665e
  Move sideways bookmark lesson-simple-todo-list from 1a6b5b543825 to 63eb68f53e65
Command or OptionExplanation
jj git pushPush updates remotely, just like git push
-allPush every branch

jj split

I had external teaching material available. Of course, it wasn't organized in a way conducive to my show-and-tell style of teaching. Here is the material I had:

  1. The Avalonia UI .NET framework.
  2. The Avalonia UI Samples . The samples were modified to use the beginner-friendly MVVM CommunityToolkit library, but otherwise the content was great. And the samples had their own step-by-step instructions: Step 1, do this; Step 2, do that. However, for teaching these self-starter instructions are problematic. They are 95% prescriptive (rather than descriptive or explanatory). Most important, the steps go in the wrong order! I want to see the end result, and then have an explanation for how to get to the end result.

My strategy was to:

  • Create one lesson per commit rather than per branch.
  • Branches were used to separate topics. For example, the "layout" topic has multiple lessons (one lesson for a grid layout, one lesson for margins and padding, etc.).

Here is roughly what I wanted the 15 lessons to look like:

mainMVVMViewLocatorCommandControlsLayoutsTodoList0 (TUTORIAL.md)1 (tasks.json)2 (instructor/src/)lesson-1lesson-2lesson-4lesson-6lesson-7lesson-9lesson-10lesson-12lesson-14lesson-3lesson-8lesson-5lesson-15lesson-11lesson-13

And I could start with a single commit that had all the content for one topic, and split that commit into multiple commits.

For example, I could start with an example application that had all the complexity of UI layouts (grids, margins, etc.) demonstrated on one page. Then with jj split I could take the single-commit "Layouts" branch:

mainLayouts0 (TUTORIAL.md)1 (tasks.json)2 (instructor/src/)everything-all-at-once

and turn it into a two-commit "Layouts" branch:

mainLayoutsLayouts (after split)0 (TUTORIAL.md)1 (tasks.json)2 (instructor/src/)everything-all-at-oncegridsmargin

It may be easier to show how you select which commits go into the first half of the split, with the remainder going into the second half:

And we can continue doing a jj split until we have the right level of granularity for each lesson.

jj log

The combination of:

  1. Doing automatic rebasing whenever jj new -A adds a commit to the main branch
  2. Splitting complex content into lesson-sized commits with jj split

is powerful but leaves us with a big problem:

How do we navigate to each lesson if the commit ids are scrambled after rebasing?

The solution is to use the commit message which will not change when rebasing.

For example, the first line of one commit message is:

1810: Introduction to Element Positioning

The 1810 is the lesson number, and the Introduction to Element Positioning is the name of the lesson.

What we would like to do is place the following task in .vscode/tasks.json:

{
    "label": "Turn to 1810",
    "detail": "Layouts. Introduction to Element Positioning",
    "type": "process",
    "command": "jj",
    "args": [
      "new",
      "-r",
      "descendants(main) & subject(regex:\"^1810:\")"
    ],
    "group": "test",
    "presentation": {
      "reveal": "silent",
      "panel": "shared",
      "showReuseMessage": false
    }
}

That will let the student press Cmd/Ctrl + Shift + P and Tasks: Run Test Task to see the menu item Turn to 1810.

And when the student chooses that menu item, the command below is run:

jj new -r 'descendants(main) & subject(regex:"^1810:")'
Command or OptionExplanation
jj newCreate a new commit
-r 'descendants(main) & subject(regex:"^1810:")'Create a detached commit branched from the commit which has the commit message starting with "1810:". Only consider branching from commits that are on a topic branch (that is, any branch that was branched from the main branch).

We can use jj log to transform all the commit messages in the topic branches into a list of Visual Studio Code tasks. We run: jj log -r 'descendants(main) & subject(regex:"^[0-9]{4}:")' --no-graph -T '"{\"id\":\"" ++ description.first_line().substr(0,4) ++ "\",\"description\":" ++ description.first_line().substr(5,1000).trim().escape_json() ++ "}\n"'.

Command or OptionExplanation
jj newCreate a new commit
-r 'descendants(main) & subject(regex:"^[0-9]{4}:")'Only log commits that are on a topic branch (that is, any branch that was branched from the main branch) and have a commit message starting with a four (4) digit number.
--no-graphDon't produce a pretty graph. Just the raw data.
-T "..."A template expression that will translate each commit into text. It uses string concatenation to create a JSON message.

I didn't want the template expression to get significantly more complicated, so I also piped the commit-derived JSON through the following:

jq -s 'sort_by(.id) |
  map({
    label:("Turn to " + .id + ""),
    detail:.description, type:"process", command:"jj",
    args:["new","-r","descendants(main) & subject(regex:\"^" + .id + ":\")"],
    group:"test",
    presentation:{reveal:"silent",panel:"shared","showReuseMessage":false}})'

Now the students can navigate the course, and we are free to add, rebase and split commits as we see fit.