Imagine your version control system not just as a rigid recorder of events, but as a flexible canvas where you can sculpt your work into a perfect narrative. Traditional systems often treat history as immutable once committed, making it a chore to refine your work after the fact. But what if you could easily fix mistakes, reorganize your thoughts, and present a pristine sequence of changes for review?

This is where Jujutsu (jj) truly shines. In this chapter, we’ll dive deep into jj’s mutable history model. You’ll learn how to refine your commit history with ease, transforming messy development into clean, logical steps. This ability is crucial for effective code reviews, simplifying debugging, and maintaining a healthy, understandable project history.

Before we begin, ensure you’ve set up jj as covered in Chapter 1 and understand the “working-copy-as-a-commit” concept from Chapter 2. We’ll build on those fundamentals to unlock jj’s true power.

The Jujutsu Approach to History

Most developers are familiar with version control systems like Git, where commits are often perceived as set in stone once pushed. While Git offers tools like rebase to rewrite history, they can feel cumbersome and are often considered “advanced” or even dangerous by newcomers. Jujutsu approaches history differently, embracing mutability as a core, safe, and intuitive principle.

What is Mutable History?

In jj, every change you make is considered a potential commit that can be easily modified, reordered, or combined. This doesn’t mean history is arbitrary or unstable; rather, it means jj provides powerful, built-in tools to refine your local development history before sharing it with others.

📌 Key Idea: In jj, your local history is a living document, constantly being shaped and improved, not a rigid, unchangeable record.

This concept extends directly from jj’s “working-copy-as-a-commit” model. Your current working directory is a commit (represented by @). Any changes you make in your working directory are implicitly modifying this special “working-copy commit.” When you run jj commit, you’re essentially creating a new commit that contains the changes from your current working directory, and the previous working directory commit becomes its parent. Crucially, you can always go back and change any of these commits, past or present, with ease and safety.

Why Mutable History is a Game-Changer for Engineers

The ability to easily manipulate history provides significant advantages in modern software engineering workflows:

  • Cleaner Code Reviews: Presenting atomic, logically separated commits makes code reviews faster and more effective. Reviewers can focus on one change at a time, reducing cognitive load and speeding up approval cycles.
  • Simplified Debugging: A linear, well-structured history with clear, focused commit messages makes tools like jj bisect (or git bisect when interoperating) incredibly powerful for finding the exact change that introduced a bug. You can quickly pinpoint the culprit.
  • Seamless Refactoring: You can refactor code across multiple commits, then easily combine or reorder them to tell a coherent story, without losing the intermediate steps you took during development. This encourages cleaner codebases.
  • Reduced Merge Conflicts: By keeping your local history clean and frequently rebased onto the main branch, you minimize the surface area for complex merge conflicts when integrating your work. This saves valuable time and reduces frustration.

Real-world insight: Many development teams enforce strict code review guidelines that require atomic, well-described commits. Jujutsu’s mutable history makes it effortless to meet these standards before pushing your changes for review, improving team collaboration and code quality.

Visualizing History Transformations

Let’s visualize how jj commands intuitively transform your commit history. Each operation replaces or modifies existing commits, creating a new, cleaner sequence.

flowchart TD subgraph OriginalHistory["Original History"] CommitA[Commit A] --> CommitB[Commit B] CommitB --> CommitC[Commit C] end CommitC --> AmendC[Amend Commit C] CommitC --> SplitC1[Split Commit C Part 1] SplitC1 --> SplitC2[Split Commit C Part 2] CommitB --> SquashBC[Squash B and C] AmendC -.->|Replaces| CommitC SplitC1 -.->|Replaces| CommitC SquashBC -.->|Replaces| CommitB SquashBC -.->|Replaces| CommitC

This diagram illustrates how basic operations like amending, splitting, and squashing effectively replace or modify parts of your commit graph, making it cleaner and more logical. Each dashed arrow indicates a replacement or transformation.

Step-by-Step Implementation: Hands-On History Sculpting

Let’s get hands-on and see jj’s mutable history in action. We’ll start with a simple repository and progressively refine its history.

First, let’s create a new jj repository for our experiments:

mkdir jj_mutable_history_demo
cd jj_mutable_history_demo
jj init

Now, let’s add some initial content and make a few commits.

1. Making Initial Commits

We’ll start with two simple commits to establish some history.

Step 1.1: Create feature_a.txt

echo "This is the first line of Feature A." > feature_a.txt
jj branch create my-feature # Create a branch to track our progress easily
jj commit -m "feat: initial setup for feature A"

You should see output similar to this, indicating a new commit was created:

Working copy now at: 3723793e82b7 (empty) feat: initial setup for feature A
Added 0 files, modified 1 files, removed 0 files

Step 1.2: Add more to feature_a.txt and a new file

echo "Adding a second line for Feature A." >> feature_a.txt
echo "This is a utility script." > utils.py
jj commit -m "feat: add more to feature A and a utility script"

Now, let’s inspect our history using jj log. This command shows the commit graph, with @ indicating your current working-copy commit.

jj log

You’ll see something like this (commit IDs will differ, but the structure is similar):

o  5041934c5b my-feature (empty) feat: add more to feature A and a utility script
|  @  3723793e82 (empty) feat: initial setup for feature A
| /
o  0000000000 (empty) (no description)

The commit 5041934c5b is the one you just created, and it’s the tip of your my-feature branch. Your working copy (@) is currently on a commit that has 5041934c5b as its parent.

2. Amending the Latest Change (jj amend)

It’s common to realize you forgot a small detail or introduced a typo immediately after committing. jj amend is perfect for incorporating these last-minute changes into the current working-copy commit’s parent, effectively updating the “latest” logical commit.

Step 2.1: Make a small correction

Let’s say we forgot to add a crucial comment to feature_a.txt.

echo "# Important comment for feature A" >> feature_a.txt

Step 2.2: Amend the previous commit

Now, instead of creating a new commit, we’ll add this change to the latest commit that my-feature points to. This commit is the parent of your current working copy (@).

jj amend

jj will apply the changes from your working directory to the parent of your working-copy commit. The original commit’s ID will be replaced with a new one that contains the combined changes.

Let’s check jj log again:

jj log

Notice that the commit ID for the my-feature branch’s tip has changed (e.g., from 5041934c5b to something new). The content of that commit now includes your latest addition, but it’s still logically a single commit. This is a core aspect of jj’s mutable history – commit IDs change when their content or parentage changes.

3. Splitting a Commit (jj split)

Sometimes, you accidentally lump unrelated changes into a single commit. This makes code reviews harder and history less clear. jj split allows you to break a commit into two or more distinct, logically separated commits.

Step 3.1: Introduce unrelated changes

Let’s modify feature_a.txt and utils.py in a single go, then commit them.

echo "Refined logic for Feature A." >> feature_a.txt
echo "Added a new helper function." > utils_helper.py # Renamed for clarity
jj commit -m "refactor: improve feature A and add helper in utils"

Your jj log now shows this new combined commit:

jj log

Step 3.2: Split the combined commit

We want to separate the feature_a.txt changes from the utils_helper.py changes. We’ll split the latest commit, which is currently the parent of our working copy (@).

jj split @

This command opens your configured editor (e.g., vi, nano, VS Code) with the diff of the commit you’re splitting.

Here’s how to interact with the editor:

  • The diff is presented in a special format. Lines starting with pick indicate changes that will go into the first resulting commit.
  • Lines starting with (empty) indicate changes that will go into subsequent commits.
  • To move a change to a new commit, you need to change its pick tag to (empty).

For our example, the editor might show something like this (actual content depends on your jj version and configuration):

# You are splitting the commit <commit_id> (e.g., d8e3f4a5b6)
#
# Lines starting with "pick" are included in the first commit.
# Lines starting with "(empty)" are included in a new subsequent commit.
#
# To move a change to a new commit, change its "pick" to "(empty)".
#
# Example:
# pick:
#   - files:
#       - src/main.rs
#     hunks:
#       - 1
# (empty):
#   - files:
#       - src/lib.rs
#     hunks:
#       - 1
#
# Hunks in commit:
#   feature_a.txt:
#     1: +Refined logic for Feature A.
#   utils_helper.py:
#     1: +Added a new helper function.

pick:
  - files:
      - feature_a.txt
    hunks:
      - 1 # The hunk with "+Refined logic for Feature A."
(empty):
  - files:
      - utils_helper.py
    hunks:
      - 1 # The hunk with "+Added a new helper function."

In the editor, we’ve already set it up so that the utils_helper.py changes are in an (empty) block, and the feature_a.txt changes remain in pick. Save and close the editor. jj will then prompt you for a commit message for the new commit(s).

First, it will ask for the message for the first commit (containing feature_a.txt changes). Let’s use: refactor: improve feature A logic. Then, it will ask for the message for the second commit (containing utils_helper.py changes). Let’s use: feat: add new helper function to utils.

After saving the commit messages, run jj log:

jj log

You’ll see two new commits replacing the original combined one, each with its specific message and changes. The original commit’s ID is gone, replaced by two new ones.

4. Squashing Changes Together (jj squash)

The opposite of splitting, jj squash allows you to combine multiple commits into a single, more comprehensive commit. This is incredibly useful for cleaning up “work-in-progress” commits or grouping related changes that were initially separated.

Step 4.1: Make a few small, related commits

Let’s make some minor UI adjustments as separate commits that we intend to combine later.

echo "UI element 1 added." > ui_changes.txt
jj commit -m "feat: add first UI element"

echo "UI element 2 added." >> ui_changes.txt
jj commit -m "feat: add second UI element"

echo "UI element 3 added." >> ui_changes.txt
jj commit -m "feat: add third UI element"

Now, jj log will show these three new commits on top of your history.

Step 4.2: Squash the UI commits

We want to combine these three UI commits into one logical commit. A common pattern is to squash the current commit (@) and its immediate parents into a single commit. We’ll squash @ into its parent, @-.

jj squash @-

The @- represents the parent of the current working-copy commit. This command takes the changes from @ and combines them with @-. jj will then prompt you to edit the commit message.

jj will open your editor, allowing you to combine or edit the commit messages of the squashed commits. It will show the messages of both commits you are squashing. Edit it to a single, coherent message, like: feat: implement all UI elements.

After saving the message, run jj log:

jj log

You’ll now see that the three separate UI commits have been replaced by a single commit with your new message, containing all the changes from the original three. The history looks much cleaner!

5. Reordering Changes (jj rebase)

jj rebase is a powerful command for changing the parent of a commit, effectively moving it and its descendants to a new location in the history. This is vital for maintaining a linear history and organizing your work, especially when integrating with a main branch or preparing for code review.

Step 5.1: Create a scenario for reordering

Let’s imagine you made an initial structural commit, then a bug fix, and then a new feature. However, you decide the bug fix should logically come after the new feature, perhaps because the feature exposes the bug, or the fix is only relevant once the feature is integrated.

# First commit: Initial structure
echo "Initial project setup." > project_init.txt
jj commit -m "feat: initial project structure"

# Second commit: A bug fix
echo "Fixed a minor issue." > bug_fix.txt
jj commit -m "fix: address minor bug"

# Third commit: New feature
echo "Implemented a key new feature." > new_feature.txt
jj commit -m "feat: introduce exciting new feature"

Your jj log will now show the history like this (top to bottom, most recent at the top):

  • feat: introduce exciting new feature (current @)
  • fix: address minor bug (@-, the parent of @)
  • feat: initial project structure (@--, the grandparent of @)

Step 5.2: Reorder the commits

We want the fix: address minor bug commit to appear after feat: introduce exciting new feature. To achieve this, we can rebase the fix: address minor bug commit (@-) onto the feat: introduce exciting new feature commit (@).

# Rebase the 'fix: address minor bug' commit (@-)
# onto the 'feat: introduce exciting new feature' commit (@)
jj rebase -s @- -d @

This command means: take the commit at @- (our “fix” commit) and rebase it so its new parent is @ (the “feature” commit).

Now, check jj log:

jj log

You’ll see the history has been reordered. The “feature” commit now appears before the “fix” commit. The order will be (top to bottom):

  • fix: address minor bug (new @)
  • feat: introduce exciting new feature (new @-)
  • feat: initial project structure (new @--)

The commit IDs will have changed, reflecting the new parentage.

🧠 Important: jj rebase is incredibly flexible. You can rebase single commits, ranges of commits, or entire branches onto any other commit using revsets. We’ll explore revsets in more detail in a later chapter, but for now, remember that jj rebase -s <source_commit> -d <destination_commit> is your go-to for moving things around. The -s flag specifies the “source” commit(s) to be rebased, and -d specifies the “destination” commit to be the new parent.

Mini-Challenge: Refine a Feature Branch

You’re working on a new feature. You’ve made some progress, but your commit history is a bit messy. Let’s clean it up!

Challenge:

  1. Initialize a new jj repository or clear your current one (rm -rf .jj and jj init).
  2. Create a file named feature_x.js.
  3. Commit 1: Add a basic function definition to feature_x.js. Commit with message: "feat: initial function skeleton"
    // feature_x.js
    function calculateSum(a, b) {
      return a + b;
    }
    
  4. Commit 2: Add implementation details to the function, but also accidentally add a commented-out line that says // TODO: remove this later. Commit with message: "feat: implement core logic"
    // feature_x.js
    function calculateSum(a, b) {
      // TODO: remove this later
      // Added some complex logic
      let result = a + b;
      if (result > 100) {
        result *= 0.9; // Apply discount
      }
      return result;
    }
    
  5. Commit 3: Add more implementation details, and also fix a typo in feature_x.js from Commit 1 (e.g., change calculateSum to sumNumbers). Commit with message: "feat: add more logic and fix typo"
    // feature_x.js
    // Typo fix: Renamed function
    function sumNumbers(a, b) {
      // TODO: remove this later
      // Added some complex logic
      let result = a + b;
      if (result > 100) {
        result *= 0.9; // Apply discount
      }
      // Even more logic
      return result + 5;
    }
    
  6. Your Task:
    • Amend the “feat: implement core logic” commit (Commit 2) to remove the // TODO: remove this later line.
    • Split the “feat: add more logic and fix typo” commit (Commit 3) into two commits: one for “add more logic” and another for “fix typo”.
    • Squash the “initial function skeleton” and the amended “implement core logic” commits into a single “feat: implement feature X” commit.
    • Ensure your final jj log shows a clean, logical history for feature_x.js with distinct commits like:
      • fix: rename calculateSum to sumNumbers
      • feat: add more logic to sumNumbers
      • feat: implement feature X (initial skeleton + core logic)

Hint: Use jj log -r @ to always know your current commit. Remember jj split @ will split the parent of @. jj squash @- will squash the current commit into its parent. If you get stuck, jj undo is your friend – it can revert any jj operation!

What to observe/learn: This challenge will help you internalize how these commands change the history and how jj guides you through the process, especially with interactive prompts for splitting and squashing. Pay attention to how commit IDs change and how the jj log output reflects your desired, clean history.

Common Pitfalls & Troubleshooting

Working with mutable history is incredibly powerful, but it comes with a few things to keep in mind, especially when migrating from a Git mindset.

  • Commit IDs Change Frequently:

    • jj generates new commit IDs whenever a commit’s content or parentage changes. This is fundamental to how jj tracks history. Don’t be alarmed if IDs you’ve seen before are replaced; it means you’ve successfully refined your history. The old (now “hidden”) commits are still in the operation log, so they aren’t truly lost.
    • Solution: Always refer to commits by their short prefixes (e.g., abcd) or by revsets like @ (current working-copy commit), @- (parent), my-branch (branch tip), or main (main branch). Avoid relying on full, long IDs for anything other than specific, one-off references.
  • Forgetting jj log:

    • It’s easy to get lost in your history, especially when actively manipulating it. The visual graph jj log provides is your map.
    • Solution: Make jj log (or jj log -r @ for just the current commit and its ancestors) your best friend. Use it frequently to visualize your changes and confirm your operations. The jj status command is also useful to see your current working directory changes.
  • Over-editing and Losing Track:

    • ⚠️ What can go wrong: While jj makes history manipulation easy, constantly changing things without a clear goal can lead to a confusing state. You might make changes, then realize you don’t remember the exact sequence of operations that led you there.
    • Solution: Plan your history changes. If you make a mistake or get into a state you don’t understand, remember the jj operation log (covered in the next chapter!) and jj undo. These are your ultimate safety nets for any history-rewriting operation. They allow you to step back through every action you’ve taken.
  • Misinterpreting jj amend vs. Git’s commit --amend:

    • In Git, git commit --amend typically amends the parent of your current HEAD. In jj, jj amend amends the parent of the current working-copy commit (@). This distinction is crucial if you’re used to Git’s model.
    • Solution: Always think of jj amend as updating the commit that jj considers your “latest” completed work. If you want to amend an ancestor further down the history, you’d typically use jj rebase -i (interactive rebase, which we’ll cover later) or combine jj edit with jj amend for more advanced scenarios. For now, jj amend is for the immediate previous commit.

Summary

Congratulations! You’ve taken a significant step into mastering Jujutsu’s powerful mutable history. You now understand how to:

  • Amend your latest commit to include new changes or fixes, seamlessly integrating them without creating new, small commits.
  • Split a single commit into multiple, more focused commits, improving clarity for code reviews and debugging.
  • Squash several related commits into one coherent change, cleaning up “work-in-progress” history.
  • Reorder commits to create a linear, logical flow using jj rebase, ensuring your history tells a clear story.

These tools empower you to sculpt your local history into a clean, review-ready state, making you a more efficient and effective developer. You’re no longer just recording history; you’re actively crafting it.

In the next chapter, we’ll explore jj’s incredible operation log and undo capabilities. This feature is jj’s ultimate safety net, allowing you to review and revert any action you’ve taken, making fear of history rewriting a thing of the past.

References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.