Welcome back! In previous chapters, we laid the groundwork for understanding Jujutsu (jj), exploring its unique working-copy-as-a-commit model, the power of revsets, and the safety net of the operation log. Now, it’s time to bridge theory with practice. How do these innovative features translate into tangible benefits in your daily coding life?

This chapter focuses on applying jj to common, real-world software engineering challenges. We’ll dive into practical scenarios that highlight jj’s ability to simplify complex tasks, making you more efficient and confident in managing your codebase’s history. Specifically, we’ll cover:

  • Streamlined Feature Development: Building new features iteratively with stacked changes.
  • Effortless Refactoring: Modifying code, even in older commits, with automatic propagation.
  • Rapid Debugging: Pinpointing and fixing bugs by exploring and manipulating past states.

By the end of this chapter, you’ll have a clear understanding of how jj can transform your development workflow, turning traditional VCS headaches into smooth, manageable processes. This guide assumes you’re familiar with basic jj commands like jj status, jj log, jj diff, jj commit, and have a foundational grasp of revsets and the operation log from earlier chapters. Let’s put jj to work!

Scenario 1: Streamlined Feature Development with Stacked Changes

Developing new features often involves a series of logical, dependent steps. You might build a foundation, then add functionality on top, then refine an earlier part. In traditional VCS like Git, modifying an early commit in such a “stack” typically requires a cumbersome interactive rebase. jj fundamentally changes this, making stacked changes a natural and easy part of your workflow.

Core Concept: Iterative Development with Mutable History

jj’s core strength for feature development lies in its working-copy-as-a-commit model and mutable history. Unlike Git, where commits are generally considered immutable, jj treats them as flexible entities. This means you can freely modify any commit in your history, and jj intelligently re-applies subsequent changes on top, minimizing manual effort.

Why this matters:

This paradigm encourages creating smaller, more focused commits, which are easier to review and understand. If code review feedback or a new insight requires changes to an earlier commit in your feature stack, jj handles the cascading updates automatically. This eliminates the fear of “breaking history” and makes iterative refinement a joy, not a chore.

Step-by-Step: Building and Refining a Feature

Let’s simulate building a new feature, user-profile-enhancements, composed of several logical steps. We’ll start by setting up a new jj repository.

# Create a new directory and initialize a jj repository
mkdir jj-feature-dev
cd jj-feature-dev
jj init --git # Initialize a jj repo with Git compatibility for future remote interaction
echo "Initial content for the project." > README.md
jj commit -m "Initial project setup" # Explicitly commit the initial state

Now, let’s begin developing our feature.

Step 1: Implementing Basic Profile Display

We’ll start with the foundational piece: displaying a user profile.

# Create a new file for user profile logic
echo "function displayProfile(user) { /* Displays basic user info */ }" > src/profile.js
jj commit -m "feat: Add basic user profile display function"

You’ve just created your first feature commit. jj automatically places it on top of your “Initial project setup” commit.

Step 2: Adding Profile Editing Functionality

Next, we’ll add the ability to edit the profile. This logically depends on the existence of the profile display.

# Append profile editing logic to the same file
echo "function editProfile(user) { /* Allows user to modify their profile */ }" >> src/profile.js
jj commit -m "feat: Implement profile editing functionality"

Now you have two commits stacked on top of each other. Let’s visualize the history:

jj log -L

You should observe an output similar to this (commit IDs will vary):

o  8b7a4c2e feat: Implement profile editing functionality
|  @  c2d1e0f9 feat: Add basic user profile display function
|  o  b1a0d3c5 Initial project setup
|/
o  00000000 (empty)

The @ symbol points to your current working copy commit, which is the latest feature commit.

Step 3: Modifying an Earlier Commit (jj edit and jj amend)

Imagine you realize that the displayProfile function (in your first feature commit) needs an additional parameter, say options, for customization. In Git, this would typically involve an interactive rebase (git rebase -i). In jj, it’s much more straightforward.

jj edit @- # Move the working copy to the parent of the current commit (the first feature commit)

After running jj edit @-, your working copy is now at the state of “feat: Add basic user profile display function”. If you run jj log -L again, you’ll see your second commit (“feat: Implement profile editing functionality”) is now marked as (abandoned). This is jj’s way of saying it’s temporarily out of the main linear history, but it’s not lost. It will be re-applied.

Now, modify src/profile.js to add the new parameter.

# Manually edit src/profile.js to change the function signature
# Example: Change 'function displayProfile(user)' to 'function displayProfile(user, options)'
# For Linux/macOS, you could use: sed -i 's/function displayProfile(user)/function displayProfile(user, options)/' src/profile.js
# On Windows, please open src/profile.js in your text editor and make the change.

Once modified, amend the current working copy commit (which is the “feat: Add basic user profile display function” commit).

jj amend # Amends the current working copy commit with your changes

Run jj log -L again. Observe the magic!

o  f2e3d4c1 feat: Implement profile editing functionality (2026-05-19)
|  @  a1b2c3d4 feat: Add basic user profile display function (2026-05-19)
|  o  b1a0d3c5 Initial project setup (2026-05-19)
|/
o  00000000 (empty)

The “feat: Add basic user profile display function” commit has been updated, and “feat: Implement profile editing functionality” has been automatically rebased on top of the new version of the first commit. jj handled the rebase seamlessly, propagating your changes through the stack.

🧠 Important: When you jj edit an older commit and then jj amend it, jj doesn’t just change that single commit. It effectively creates a new version of that commit and then automatically rebases all subsequent commits onto this new version. This is the core of jj’s mutable history power.

Step 4: Squashing Commits for a Cleaner History (jj squash)

Before merging your feature into the main branch, you might want to combine these two logically related commits into a single, cohesive change for a cleaner history.

jj squash @- # Squash the current commit into its parent

This command squashes the current commit (the one with profile editing) into its parent (the one with basic display, which you just modified). jj will open your configured editor, allowing you to combine and refine the commit messages. Save and close the editor.

After squashing, jj log -L will show a single, consolidated feature commit:

o  d5c6b7a8 feat: Add user profile display and editing (2026-05-19)
|  @  b1a0d3c5 Initial project setup (2026-05-19)
|/
o  00000000 (empty)

You’ve successfully built a feature iteratively, modified an earlier part, and then cleaned up the history—all with minimal fuss and maximum flexibility.

Mini-Challenge: Reordering Commits

Challenge: Create a new commit that adds a utils.js file with a simple helper function (e.g., isValidString). Then, reorder your history so this utils.js commit becomes a direct parent of your feat: Add user profile display and editing commit.

Hint:

  1. Use jj new to create a new empty commit.
  2. Add src/utils.js with your helper function.
  3. jj commit -m "feat: Add utility helper functions".
  4. Then, think about how jj rebase can change a commit’s parent. You’ll need to rebase your feature commit onto the new utility commit. You can use jj rebase -s <feature_commit_id> -d <utility_commit_id>.

What to observe/learn: jj rebase is incredibly versatile. It can change a commit’s parent, effectively reordering history and making dependencies explicit. This is a powerful tool for structuring your changes logically.

Scenario 2: Effortless Refactoring with Mutable History

Refactoring is a vital practice for maintaining code quality and readability, but it can be daunting in traditional VCS, especially when changes span multiple existing commits or require modifications to older code. jj’s mutable history model makes refactoring a much safer and more pleasant experience.

Core Concept: Refactoring in Place, Propagating Changes

jj empowers you to “travel back in time” to any older commit, apply a refactoring, and then trust jj to automatically rebase all subsequent commits on top of your refactored base. This means you can identify and fix structural issues in existing code, even if it’s deeply buried in your history, without the manual burden of re-applying all your newer changes. The jj op log also provides an “undo” safety net for any experimental refactors.

Why it exists:

This approach directly addresses the challenge of “refactoring debt.” Often, as a codebase evolves, you discover a better architectural pattern or a more efficient way to structure code that was written several commits ago. jj liberates you from living with suboptimal code or performing painful, error-prone manual rebases. You can fix the code at its logical origin and let the system handle the updates.

Step-by-Step: Applying a Refactor to Past Code

Let’s continue in our jj-feature-dev repository. We’ll add a few more commits and then perform a refactor that affects an earlier commit.

# Ensure we have a few commits on top of our feature
cd jj-feature-dev
jj new # Create a new empty commit as the current working copy commit
echo "console.log('User profile loaded.');" >> src/profile.js
jj commit -m "chore: Add debug logging to profile module"
jj new
echo "Bug introduced: data missing." >> src/profile.js
jj commit -m "fix: Accidental bug in profile data handling"
jj log -L

Now, imagine we want to refactor how displayProfile and editProfile are managed. Instead of being global functions, we decide they should be methods within a ProfileManager class for better encapsulation. This is a structural change that affects our feat: Add user profile display and editing commit.

Step 1: Identify the Refactoring Target

First, let’s find the commit ID of our combined feature commit, “feat: Add user profile display and editing.”

jj log --short

Let’s assume its ID is d5c6b7a8.

Step 2: Go Back and Apply the Refactoring

We’ll use jj edit to temporarily move our working copy to the state of that specific commit.

jj edit d5c6b7a8 # Replace with your actual commit ID for the feature

Your working copy is now at the state of d5c6b7a8. If you check jj log -L, you’ll see the subsequent commits (like “chore: Add debug logging…” and “fix: Accidental bug…”) are now marked (abandoned). Again, they are not lost; jj will bring them back.

Now, let’s perform the refactoring. We’ll simulate moving the functions into a ProfileManager class.

# Simulate refactoring src/profile.js
# Create a new class structure
echo "class ProfileManager {" > src/profile_manager.js
echo "  displayProfile(user, options) { /* ... */ }" >> src/profile_manager.js
echo "  editProfile(user) { /* ... */ }" >> src/profile_manager.js
echo "}" >> src/profile_manager.js
echo "export const profileManager = new ProfileManager();" >> src/profile_manager.js

# For simplicity in this demo, we'll conceptually replace the old file.
# In a real scenario, you'd modify src/profile.js to use the new class or rename it.
rm src/profile.js

After making your refactoring changes, you need to “commit” them. We’ll amend the current feature commit to include this refactoring, effectively changing how the feature was originally implemented.

jj amend --message "refactor(profile): Introduce ProfileManager class for user operations"

You can also choose to create a new commit for the refactoring on top of d5c6b7a8 using jj new --message "...". The choice depends on whether you want the refactor to be part of the original commit or a separate, subsequent change. For this scenario, amending is often preferred to clean up the original feature implementation.

Step 3: Observe Automatic Propagation

Now, let’s observe your history:

jj log -L

You’ll see that your refactor (which is now part of your main feature commit) is in the history, and the chore: Add debug logging... and fix: Accidental bug... commits have been automatically rebased on top of this new, refactored base. jj handles the propagation of changes, meaning you don’t have to manually re-apply the logging or bug fix on top of your refactored code.

📌 Key Idea: jj edit followed by jj amend (or jj new) allows you to non-destructively modify any commit in your history. jj then automatically re-applies all descendant commits, minimizing manual rebase effort.

Mini-Challenge: Using jj undo During Refactoring

Challenge: Imagine you’ve made a complex refactoring as above, but after reviewing the rebased history and perhaps running tests, you realize the refactor introduced more problems than it solved, or it’s simply not the right approach. Use jj undo to revert your entire refactoring session, effectively going back to the state before you started the refactor.

Hint: The jj op log (jj ol) shows a history of your jj operations. You can identify the operation where you started the refactor and use jj undo to revert to the state just before it.

What to observe/learn: The operation log is your ultimate safety net for complex changes. jj undo can revert entire sequences of operations, making experimentation and bold refactorings much less risky.

Scenario 3: Rapid Debugging with History Exploration

Debugging can be one of the most time-consuming aspects of development, especially when a bug was introduced many commits ago and its origin is unclear. jj’s powerful history navigation and manipulation, combined with the comprehensive operation log, provide a robust toolkit for quickly finding, understanding, and fixing issues.

Core Concept: Pinpointing Bugs and Applying Fixes

jj treats every state of your repository as a first-class citizen. You can effortlessly jump to any past commit, examine its exact state, run tests against it, and even craft a fix directly on that problematic commit. Once the fix is created, jj can then rebase your current work on top, ensuring the fix propagates correctly and cleanly into your current development line.

What problem it solves:

This workflow offers a significant advantage over traditional git bisect or manual cherry-picking. Instead of just identifying a commit, you can interact with the exact historical state where the bug originated. You can test your fix in isolation at that point and then seamlessly integrate it into your current work, resulting in a cleaner, more accurate history.

Step-by-Step: Debugging a Simulated Issue

Let’s continue with our jj-feature-dev repository. We’ll introduce a subtle bug and then use jj’s capabilities to find and fix it efficiently.

cd jj-feature-dev
jj new # Create a new empty commit
echo "function processUserData(data) { return data; }" >> src/data_processor.js
jj commit -m "feat: Add basic data processing utility"
jj new
echo "function processUserData(data) { if (!data) throw new Error('No data provided'); return data.toUpperCase(); }" >> src/data_processor.js # This is our bug!
jj commit -m "fix: Validate and format user data"
jj new
echo "console.log(profileManager.displayProfile({name: 'Alice'}));" >> src/main.js
jj commit -m "feat: Integrate profile display into main app"
jj log -L

Now, imagine you discover that your application crashes with an error like “data.toUpperCase is not a function” when processUserData is called with certain valid inputs (e.g., a number). You suspect the bug was introduced in the “fix: Validate and format user data” commit because that’s where the toUpperCase() logic was added.

Step 1: Inspect History and Identify Potential Culprits

First, let’s use jj log to examine recent changes and identify the commit where the bug might have been introduced.

jj log -r 'root()..' --short

This command shows all commits from the repository’s root to the current working copy. Based on our scenario, the “fix: Validate and format user data” commit (e8f9g0h1 in our example, your ID will differ) looks like the primary suspect.

Step 2: Examine the Faulty Commit’s Changes

Let’s use jj diff to see exactly what changes were introduced in that specific commit. Assuming the ID is e8f9g0h1 for “fix: Validate and format user data”:

jj diff e8f9g0h1

You’ll see the line return data.toUpperCase();. This immediately looks suspicious for inputs that are not strings. The bug is confirmed.

Step 3: Go Back and Fix the Bug

Now, let’s edit that commit to apply a precise fix.

jj edit e8f9g0h1 # Replace with the actual commit ID of the bug

Your working copy is now at the exact state of e8f9g0h1. Any subsequent commits (like “feat: Integrate profile display…”) are marked (abandoned).

Let’s fix the bug by ensuring data is a string before calling toUpperCase().

# Correct src/data_processor.js
# Manually edit the file or use sed (Linux/macOS)
# Example:
# Change: return data.toUpperCase();
# To:     return typeof data === 'string' ? data.toUpperCase() : String(data).toUpperCase();
# Or:     return String(data).toUpperCase(); // Simpler for this example

Now, amend the commit with the fix. This will update the “fix: Validate and format user data” commit.

jj amend --message "fix: Ensure data is string before calling toUpperCase in data processor"

Step 4: Propagate the Fix and Verify

After amending, jj automatically rebases any subsequent commits (like “feat: Integrate profile display into main app”) on top of your fixed commit.

jj log -L

You’ll see the updated “fix: Ensure data is string…” commit, and any commits that were abandoned will have been re-applied on top. The bug is now fixed at its source, and your history is clean and accurate.

⚡ Real-world insight: For more complex debugging scenarios, you might use jj restore --from <revset> --file <path> to cherry-pick specific files or even individual hunks from other commits while editing a problematic one, allowing for highly granular control over your fix.

Mini-Challenge: Undoing a Debugging Session

Challenge: After fixing a bug and seeing the history update, you realize your fix introduced a new, worse problem, or perhaps you found an even better way to fix it. Use the operation log (jj op log) and jj undo to revert the entire sequence of debugging and fixing, effectively going back to the state before you started trying to fix the bug.

Hint: Run jj op log to see a list of your recent jj commands. Find the operation ID corresponding to the state before you started your fix (e.g., before the jj edit command). Then use jj undo --op <operation_id>.

What to observe/learn: The operation log is your ultimate safety net for jj commands. It allows you to rewind not just code changes but also the actions you took on your repository, providing powerful recovery capabilities for even the most complex debugging sessions.

Common Pitfalls & Troubleshooting

While jj dramatically simplifies many workflows, certain aspects can still be confusing, especially when transitioning from a Git-centric mindset.

  • Forgetting jj’s Mutable History:
    • ⚠️ What can go wrong: Coming from Git, you might expect jj amend to only affect the last commit. In jj, jj amend always modifies the current working copy commit (@). If you jj edit an older commit, then jj amend, you are indeed changing that older commit, and jj will automatically rebase all its descendants.
    • Pro tip: Trust jj to rebase. It’s designed for this. If you see (abandoned) commits, it’s usually jj preparing to re-apply them on a new base.
  • Over-reliance on jj undo for Complex Rewrites:
    • ⚠️ What can go wrong: While jj undo is fantastic for quick rollbacks of the last few operations, for very complex history rewrites involving many steps, repeatedly using jj undo might not be the clearest path.
    • Pro tip: For intricate history manipulation, it’s often more transparent to use targeted commands like jj rebase, jj fold, jj squash, or jj restore. jj undo is best for immediate course corrections.
  • Initial Difficulty with revsets Syntax:
    • ⚠️ What can go wrong: revsets are incredibly powerful for selecting revisions but can have a steep learning curve due to their rich syntax.
    • Pro tip: Start with simple revsets like @ (current), @- (parent of current), root() (repository root), main (branch head), feature.. (all commits reachable from feature not from its parent). Gradually explore more complex operators as needed. Keep the official revsets documentation handy.
  • Merge Conflicts During Automatic Rebase:
    • ⚠️ What can go wrong: jj excels at automatically rebasing changes. However, if two commits modify the same lines of code in fundamentally incompatible ways, jj will pause and require you to resolve the conflicts manually, much like Git.
    • Troubleshooting: When conflicts occur, jj will inform you. Use jj status to see the unmerged files. Manually edit the files to resolve the conflict markers (e.g., <<<<<<<, =======, >>>>>>>). Once resolved, jj commit --amend will commit the resolution and allow jj to continue the rebase process.

Summary: Empowering Your Development Workflow

In this chapter, we’ve explored three crucial real-world scenarios, demonstrating how Jujutsu’s unique features can dramatically enhance your daily development experience:

  • Feature Development: jj makes building features iteratively with stacked changes incredibly intuitive and efficient. Modifying earlier commits in a stack is no longer a headache, thanks to jj’s automatic rebase capabilities.
  • Refactoring: You can confidently refactor any part of your codebase, even old commits, knowing that jj will handle the complex propagation of changes and that jj undo provides a robust safety net for experimentation.
  • Debugging: Pinpointing and fixing bugs in historical commits is streamlined. jj edit allows you to interact directly with past states, apply precise fixes, and then seamlessly integrate them into your current development line.

By embracing jj’s mutable history, powerful revsets, and comprehensive operation log, you’re not just using a version control system; you’re gaining a highly flexible and resilient tool that adapts to your workflow, rather than forcing you into rigid, traditional patterns. This flexibility fosters cleaner history, easier code reviews, and greater confidence in managing your codebase.

What’s Next?

You’ve now covered the core workflows and essential real-world applications of jj. You’re well on your way to mastering this powerful VCS. In the next chapter, we’ll delve into even more advanced topics, including customizing jj for personal productivity, exploring advanced revsets patterns in depth, and integrating jj into CI/CD pipelines for large-scale projects. Get ready to unlock the full potential of Jujutsu!

References


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