In previous chapters, we established a solid foundation by exploring jj’s core concepts such as the working-copy-as-a-commit and mutable history. Now, we’re ready to delve into one of jj’s most distinctive and powerful features: branchless workflows.

This chapter will introduce a new way of thinking about managing your development work. Instead of navigating Git’s often intricate branching model, you’ll discover how jj facilitates a simpler, more linear approach using stacked changes and lightweight bookmarks. This shift can significantly contribute to cleaner history, simplified merges, and more effective code reviews.

To maximize your learning from this chapter, ensure you are comfortable with basic jj commands like jj status, jj log, and jj commit, and have a clear understanding of jj’s working-copy-as-a-commit model. Let’s streamline your version control experience.

The Branchless Philosophy of Jujutsu

Traditional Git workflows often revolve around named branches. Developers create a branch, work on it, merge it back into a main line, and frequently delete it. This can lead to a proliferation of short-lived branches, complex merge graphs, and the cognitive load of constantly managing context switches between branches.

jj introduces a different paradigm. Rather than relying on named branches that act as pointers and automatically move as you commit, jj places its focus on the commits themselves and their explicit parent-child relationships. Your primary method of organizing ongoing work isn’t a named branch, but a stack of changes – a linear series of dependent commits, where each commit logically builds upon the one preceding it.

📌 Key Idea: jj’s branchless approach prioritizes explicit changes (commits) and their direct lineage, rather than relying on named, moving branch pointers for feature development.

Why Branchless Matters

Adopting a branchless workflow with jj offers several compelling advantages:

  • Cleaner History: Your project history tends to be more linear and easier to trace. The emphasis is on continuously refining and reordering individual commits, rather than generating complex, interwoven merge graphs.
  • Simplified Merges: With a predominantly linear history, jj’s rebase-centric approach makes incorporating upstream changes significantly smoother. Conflicts are often addressed incrementally, commit by commit, making them more manageable.
  • Easier Code Reviews: Reviewers can inspect a stack of small, focused changes, which allows them to understand the progression of your work step-by-step. This often leads to more targeted and effective feedback.
  • Reduced Mental Overhead: Less time spent on the mechanics of branch management translates to more time and focus dedicated to actual coding and problem-solving.

Stacked Changes: Building Features Incrementally

The foundation of jj’s branchless workflow is the concept of stacked changes. Envision developing a new feature not as a single, monolithic commit, but as a series of small, logical, and incremental steps. Each of these steps becomes a distinct, focused commit that directly builds upon the functionality introduced by the previous one.

flowchart TD A[Main Branch Tip] --> B[Add UI Component] B --> C[Implement Data Fetching] C --> D[Connect UI to Data] D --> E[Working Copy Draft]

In this illustration, Commit 1 serves as the base. Commit 2 adds functionality that depends on Commit 1, and Commit 3 further builds upon Commit 2. Your Working Copy represents a draft commit that sits logically on top of Commit 3. This structured stack makes your changes transparent and highly reviewable.

Creating Your First Stack

Let’s begin by creating a new jj repository or navigating to an existing one. We’ll simulate the process of developing a new feature.

  1. Initialize a new jj repository (if you don’t have one):

    mkdir my_feature_project
    cd my_feature_project
    jj init
    

    This command initializes a new jj repository. If you run jj log immediately, you’ll see an initial root commit.

  2. Create your first change in the stack: We’ll start by adding a new file.

    echo "This is the initial setup for the feature." > feature_setup.txt
    jj new
    

    The jj new command creates a new empty commit and moves your working copy to it. This new commit is a child of your current (parent) commit. Any changes you make in your working directory now will be associated with this new draft commit.

    Let’s incorporate feature_setup.txt into this new commit. Remember, in jj, the working copy is a draft commit.

    jj commit -m "feat: Initial setup for the new feature"
    

    jj commit here takes all modifications from your working directory and adds them to the current working-copy commit, simultaneously assigning it the provided message. Your working copy remains at this newly updated commit.

  3. Add a second, dependent change: Now, let’s build upon that first commit. We’ll add another file that logically extends the first step.

    echo "This file extends the feature setup." > feature_component.txt
    jj new
    

    Again, jj new creates a new empty commit on top of your previous one and automatically moves your working copy to it. Now, let’s incorporate feature_component.txt into this new, current commit.

    jj commit -m "feat: Add a new component for the feature"
    

    You now have a stack of two new commits. Let’s visualize them.

    jj log
    

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

    @  xxxxxx feat: Add a new component for the feature
    o  yyyyyy feat: Initial setup for the new feature
    o  zzzzzz Initial commit (root)
    

    The @ symbol denotes your current working copy commit. Notice how jj log naturally displays the commits in a stack-like, linear order.

When working with stacked changes, you’ll frequently need to move your working copy up and down the stack to inspect or modify earlier commits. The jj go command is your primary tool for this.

  • jj go -: Moves your working copy to the previous commit in the operation log (similar to cd - in the shell).
  • jj go @~: Moves your working copy to the parent of the current working copy commit.
  • jj go <commit_id>: Moves your working copy to a specific commit identified by its ID.

Let’s practice navigating:

  1. Move to the parent commit:

    jj go @~
    

    Now, if you run jj log, you’ll see your working copy (@) has moved to the feat: Initial setup for the new feature commit.

    jj log
    

    You’ll observe:

    o  xxxxxx feat: Add a new component for the feature
    @  yyyyyy feat: Initial setup for the new feature
    o  zzzzzz Initial commit (root)
    

    Notice that feature_component.txt is no longer present in your working directory. This is because your working copy is now positioned at the yyyyyy commit, which does not yet include the changes from the subsequent commit.

  2. Move back to the latest commit: The latest commit in your stack is the child of your current commit. You can return to it using the operation log.

    jj go -
    

    This command will return your working copy to the commit where you were just previously working.

    ⚡ Quick Note: jj go is an incredibly versatile command for traversing history. It effectively “checks out” the state of the specified commit by moving your working copy to it.

Bookmarks: jj’s Lightweight Branch Alternative

While jj strongly advocates for branchless workflows, there are scenarios where a named reference to a specific commit is beneficial, particularly for collaboration or when interacting with Git repositories. This is where bookmarks become useful.

Bookmarks in jj are functionally similar to local Git tags, but with a crucial difference: they can be pushed and pulled to remotes. This makes them suitable for tracking feature branches in a Git-compatible manner. A key distinction is that jj bookmarks do not move automatically when you create new commits. They remain fixed pointers to a specific commit unless you explicitly choose to move them.

This behavior contrasts sharply with Git branches, which automatically advance to point to the latest commit every time you commit on that branch.

Working with Bookmarks

Let’s create a bookmark for our feature stack to see how they work.

  1. Ensure your working copy is at the top of your feature stack:

    jj log -r @
    

    Verify that your working copy (@) is at the feat: Add a new component for the feature commit. If it’s not, use jj go <commit_id> to navigate there.

  2. Create a bookmark:

    jj bookmark my_awesome_feature
    

    This command creates a bookmark named my_awesome_feature that points to your current working-copy commit.

  3. List your bookmarks:

    jj bookmark list
    

    You should see your newly created bookmark (commit ID will vary):

    my_awesome_feature: xxxxxx feat: Add a new component for the feature
    
  4. Observe bookmark immutability: Let’s add another commit and observe how the bookmark behaves.

    echo "Final touches for the feature." > feature_final.txt
    jj new
    jj commit -m "feat: Add final touches"
    jj log
    jj bookmark list
    

    You’ll notice that your my_awesome_feature bookmark still points to the previous commit (xxxxxx), not the new one (yyyyyy) where your working copy is now located.

    @  yyyyyy feat: Add final touches
    o  xxxxxx feat: Add a new component for the feature
    | my_awesome_feature
    o  zzzzzz feat: Initial setup for the new feature
    o  aaaaaa Initial commit (root)
    

    To move a bookmark, you would explicitly point it to a new commit using jj bookmark my_awesome_feature -r @ (to move it to the current working copy).

    ⚡ Real-world insight: Bookmarks are excellent for marking “save points” or referencing specific versions of a feature that you might want to share or discuss with others, without the dynamic movement overhead of Git’s branches.

  5. Delete a bookmark:

    jj bookmark delete my_awesome_feature
    jj bookmark list
    

    The bookmark has now been removed.

Refining Your Stack: Modifying and Reordering Changes

One of jj’s most powerful capabilities is its ease in modifying and reordering commits within your stack. This is where jj’s mutable history truly shines, enabling you to craft a clean, logical history before you share your work.

jj squash: Combining Commits

During development, it’s common to make several small, incremental commits that, in retrospect, would be better combined into a single, more comprehensive commit. The jj squash command is perfectly suited for this.

Let’s recreate our two initial feature commits to demonstrate:

# Ensure you're at a clean base commit, e.g., the root or main branch
jj go root() # Moves to the very first commit in the repository

jj new
echo "First part of the feature." > part1.txt
jj commit -m "feat: Add first part"

jj new
echo "Second part of the feature." > part2.txt
jj commit -m "feat: Add second part"

jj log

You should now have a linear history with two new commits: feat: Add second part on top of feat: Add first part.

Now, let’s squash them into one single commit:

jj squash @~

This command squashes the parent of the current commit (@~) into the current commit (@). jj will then open your default editor, allowing you to combine and refine the commit messages.

After saving the combined message, run jj log again. You’ll observe that the two original commits have been merged into a single one, and your working copy is now positioned at this new, consolidated commit.

@  xxxxxx feat: Add first part and second part (combined message)
o  yyyyyy Initial commit (root)

⚠️ What can go wrong: If you accidentally squash into the wrong commit or make an unintended change, remember that jj undo is your immediate safety net!

jj amend -i: Interactively Modifying an Existing Commit

Sometimes you realize you forgot to include a file, made a typo in a commit message, or want to split an existing commit. jj amend -i (interactive amend) allows you to modify the current working-copy commit or even re-stage changes.

Let’s say you just committed, but realize you forgot a crucial file:

# Assuming your working copy is at a commit you just made
echo "Forgot this important file!" > important.txt
jj amend -i

This command will open an interactive prompt, allowing you to choose which changes from your working directory to include in the current commit. You can also edit the commit message.

jj rebase: Reordering and Moving Commits

jj rebase is an exceptionally flexible command. It can move commits, reorder them, or even insert new commits into an existing history. This capability is fundamental for maintaining a clean, logical feature stack and for keeping it synchronized with upstream changes.

Let’s set up a scenario where we have two independent lines of development that we want to merge linearly.

First, ensure you’re at a clean base commit (e.g., the root of the repository):

jj go root() # Moves to the very first commit in the repository

Now, let’s create two parallel feature lines, both stemming from this root commit:

# Create the first feature line (Feature A)
jj new # Creates a new commit (let's call it A1) on top of root
echo "Feature A: Initial component." > feature_a.py
jj commit -m "feat: Add Feature A's core component"

# Create the second feature line (Feature B), also on top of root
# We need to explicitly tell `jj new` to parent this commit to root()
jj new -r root() # Creates a new commit (let's call it B1) on top of root
echo "Feature B: Configuration setup." > feature_b.cfg
jj commit -m "feat: Add Feature B's configuration"

jj log

Your jj log output should now show two commits (A1 and B1) both having root() as their parent. Your working copy (@) will be at B1.

@  xxxxxx feat: Add Feature B's configuration
o  yyyyyy feat: Add Feature A's core component
o  zzzzzz Initial commit (root)

Notice xxxxxx and yyyyyy are siblings, both children of zzzzzz.

Now, let’s rebase feat: Add Feature B's configuration (the commit at xxxxxx) to sit on top of feat: Add Feature A's core component (the commit at yyyyyy). This will make our history linear. We can refer to the “Feature A” commit using the root()+' revset, which means “the first child of the root commit”.

jj rebase -s @ -d 'root()+'

After running this, jj log will show the feat: Add Feature B's configuration commit now sits on top of feat: Add Feature A's core component, making your stack beautifully linear.

@  xxxxxx feat: Add Feature B's configuration
o  yyyyyy feat: Add Feature A's core component
o  zzzzzz Initial commit (root)

🔥 Optimization / Pro tip: jj rebase is your primary tool for keeping your feature stack up-to-date with the main or master branch. Regularly rebase your feature stack onto the latest upstream changes to avoid large, complex merges later.

Mini-Challenge: Developing a Feature with Stacked Changes and Bookmarks

Now it’s your turn to apply what you’ve learned. You’ll simulate developing a new feature, using stacked commits and a bookmark.

Challenge: Implement a simple “User Profile” feature.

  1. Start a new jj project or ensure your current one is clean.
  2. Create an initial commit for the feature: Add a file profile_data_model.py with some placeholder content.
    • Commit message: feat: Define user profile data model
  3. Add a second, dependent commit: Add a file profile_api.py that would theoretically use the data model.
    • Commit message: feat: Implement basic profile API endpoint
  4. Create a bookmark named user_profile_feature that points to the latest commit in your stack.
  5. Add a third, final commit to the stack: Add a file profile_ui.js with some placeholder UI code.
    • Commit message: feat: Develop profile UI component
  6. Squash the first two commits (profile_data_model.py and profile_api.py) into a single, more comprehensive “Profile Core” commit. Update the commit message accordingly.
  7. Verify your history with jj log and jj bookmark list. Your user_profile_feature bookmark should still point to the original profile_api.py commit (before it was squashed), and your history should reflect the new, squashed commit.

Hint:

  • Remember jj new to create new commits in your stack.
  • Use jj commit -m "message" to finalize changes in the working copy’s draft commit.
  • jj bookmark <name> creates a static bookmark.
  • jj squash @~ will squash the parent into the current commit.
  • Use jj log frequently to inspect your history.

What to observe/learn:

  • How jj new facilitates building a linear, incremental stack.
  • The effect of jj squash on simplifying your history.
  • The non-moving nature of jj bookmarks in contrast to Git branches.

Common Pitfalls & Troubleshooting

  • Misunderstanding Bookmark Behavior: The most frequent pitfall for users migrating from Git is expecting jj bookmarks to behave like Git branches (i.e., automatically moving forward with jj commit). It’s crucial to remember that jj bookmarks are static pointers. If you intend for a bookmark to track your active development, you must explicitly move it using jj bookmark <name> -r @.
  • Forgetting jj go: When managing stacked changes, you will often need to move your working copy to an earlier commit to make a correction, perform an inspection, or branch off from a specific point. Forgetting to use jj go and attempting to modify an earlier commit while your working copy is at the top of the stack can lead to confusion. Always check jj log to confirm the position of @ (your working copy).
  • Initial Difficulty with revsets for Commit Selection: While using commit IDs is perfectly fine for simple operations, selecting specific commits for commands like jj rebase or jj squash becomes much more powerful with revsets. Initially, stick to IDs, but as you gain experience, learning revsets like @~ (parent), @+ (child), root().. (all commits from root) will prove invaluable for complex history manipulation.
  • Not Utilizing jj undo: Experimenting with commands like jj squash or jj rebase can sometimes lead to unintended outcomes. There’s no need to panic! jj undo is your robust safety net. It can revert almost any jj operation, allowing you to easily correct mistakes and try again without losing work.

Summary

You’ve made significant progress in understanding jj’s unique approach to version control. In this chapter, we thoroughly explored:

  • Branchless Workflows: The philosophy behind how jj simplifies development by emphasizing a linear history of changes over complex branching models.
  • Stacked Changes: The powerful technique of building features as a series of small, dependent commits, which enhances manageability for both development and code review.
  • jj new and jj commit: The fundamental commands for creating new commits and incorporating changes into your working copy’s draft commit.
  • jj go: Your essential tool for navigating up and down your commit stack, effectively checking out historical states.
  • Bookmarks: jj’s lightweight, static alternative to Git branches, ideal for marking specific commits for reference, sharing, or integration with Git remotes.
  • Refining History: How to use jj squash to combine related commits and jj rebase to reorder commits, enabling you to craft a clean, logical, and presentable history.
  • jj amend -i: The command for interactively modifying an existing working-copy commit.

By internalizing and applying these concepts, you are moving towards a more efficient and less cumbersome version control experience. In the next chapter, we will delve deeper into revsets, jj’s powerful language for selecting and filtering commits, which will unlock even more advanced workflows and make navigating complex histories remarkably simple.


References

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