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.
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.
Initialize a new
jjrepository (if you don’t have one):mkdir my_feature_project cd my_feature_project jj initThis command initializes a new
jjrepository. If you runjj logimmediately, you’ll see an initial root commit.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 newThe
jj newcommand 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.txtinto this new commit. Remember, injj, the working copy is a draft commit.jj commit -m "feat: Initial setup for the new feature"jj commithere 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.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 newAgain,
jj newcreates a new empty commit on top of your previous one and automatically moves your working copy to it. Now, let’s incorporatefeature_component.txtinto 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 logYou 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 howjj lognaturally displays the commits in a stack-like, linear order.
Navigating Your Stack with jj go
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 tocd -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:
Move to the parent commit:
jj go @~Now, if you run
jj log, you’ll see your working copy (@) has moved to thefeat: Initial setup for the new featurecommit.jj logYou’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.txtis no longer present in your working directory. This is because your working copy is now positioned at theyyyyyycommit, which does not yet include the changes from the subsequent commit.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 gois 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.
Ensure your working copy is at the top of your feature stack:
jj log -r @Verify that your working copy (
@) is at thefeat: Add a new component for the featurecommit. If it’s not, usejj go <commit_id>to navigate there.Create a bookmark:
jj bookmark my_awesome_featureThis command creates a bookmark named
my_awesome_featurethat points to your current working-copy commit.List your bookmarks:
jj bookmark listYou should see your newly created bookmark (commit ID will vary):
my_awesome_feature: xxxxxx feat: Add a new component for the featureObserve 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 listYou’ll notice that your
my_awesome_featurebookmark 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.
Delete a bookmark:
jj bookmark delete my_awesome_feature jj bookmark listThe 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.
- Start a new
jjproject or ensure your current one is clean. - Create an initial commit for the feature: Add a file
profile_data_model.pywith some placeholder content.- Commit message:
feat: Define user profile data model
- Commit message:
- Add a second, dependent commit: Add a file
profile_api.pythat would theoretically use the data model.- Commit message:
feat: Implement basic profile API endpoint
- Commit message:
- Create a bookmark named
user_profile_featurethat points to the latest commit in your stack. - Add a third, final commit to the stack: Add a file
profile_ui.jswith some placeholder UI code.- Commit message:
feat: Develop profile UI component
- Commit message:
- Squash the first two commits (
profile_data_model.pyandprofile_api.py) into a single, more comprehensive “Profile Core” commit. Update the commit message accordingly. - Verify your history with
jj logandjj bookmark list. Youruser_profile_featurebookmark should still point to the originalprofile_api.pycommit (before it was squashed), and your history should reflect the new, squashed commit.
Hint:
- Remember
jj newto 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 logfrequently to inspect your history.
What to observe/learn:
- How
jj newfacilitates building a linear, incremental stack. - The effect of
jj squashon simplifying your history. - The non-moving nature of
jjbookmarks in contrast to Git branches.
Common Pitfalls & Troubleshooting
- Misunderstanding Bookmark Behavior: The most frequent pitfall for users migrating from Git is expecting
jjbookmarks to behave like Git branches (i.e., automatically moving forward withjj commit). It’s crucial to remember thatjjbookmarks are static pointers. If you intend for a bookmark to track your active development, you must explicitly move it usingjj 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 usejj goand attempting to modify an earlier commit while your working copy is at the top of the stack can lead to confusion. Always checkjj logto confirm the position of@(your working copy). - Initial Difficulty with
revsetsfor Commit Selection: While using commit IDs is perfectly fine for simple operations, selecting specific commits for commands likejj rebaseorjj squashbecomes much more powerful withrevsets. Initially, stick to IDs, but as you gain experience, learningrevsetslike@~(parent),@+(child),root()..(all commits from root) will prove invaluable for complex history manipulation. - Not Utilizing
jj undo: Experimenting with commands likejj squashorjj rebasecan sometimes lead to unintended outcomes. There’s no need to panic!jj undois your robust safety net. It can revert almost anyjjoperation, 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
jjsimplifies 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 newandjj 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 squashto combine related commits andjj rebaseto 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
- Jujutsu GitHub Repository: https://github.com/jj-vcs/jj
- Jujutsu Tutorial (main branch): https://github.com/jj-vcs/jj/blob/main/docs/tutorial.md
- Jujutsu Bookmarks Documentation: https://github.com/jj-vcs/jj/blob/main/docs/bookmarks.md
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.