How I Keep Private Changes Clean in a Jujutsu Megamerge Workflow

日本語版記事はこちら

Introduction

Recently, I briefly introduced the Jujutsu Megamerge Workflow. 1

In this post, I will share examples of how I handle private changes using the Megamerge Workflow. The intended readers are those who have a general understanding of the Megamerge Workflow but cannot quite imagine the specific situations where it becomes useful. If you want to learn more about the Megamerge Workflow, please refer to the following articles.

By using Megamerge Workflow, you can keep "messy commits in progress" or "local-only changes" while pushing only cleaned-up, organized changes to the remote. While this requires complex state management in Git, Jujutsu allows you to eliminate it entirely.

Megamerge Workflow and Private Changes

As mentioned in the previous article 1, I often commit changes to the repository as "private changes" when I want them reflected in the working copy but not on the remote (or don't need them to be).

Specifically, these include Nix Flake-related files (flake.nix, flake.lock) and repository-local dictionary files for Harper (.harper-dictionary). Regarding flake.nix and flake.lock, there is a constraint on the Nix side where evaluation is not possible unless the files are in a state tracked by Git. 2 I often use Nix devShells to prepare the tools necessary for development. However, since other team members do not use Nix, the communication cost of introducing files that only I use into the team repository requiring them to be reviewed is too high. Therefore, I would prefer to commit these as "private changes" confined to my local environment.

If using only Git, such changes would need to be stashed using git stash before pushing. With the Megamerge Workflow, you can keep all changes in the working copy and only carve out the parts you want to push to the remote.

In the Megamerge Workflow, the working copy contains all of the following:

This way, flake.nix and flake.lock are always tracked by Git (Jujutsu), while being managed only in my local environment without being pushed to the remote. They are truly "private changes."

When I want to reflect changes on the remote, I create a bookmark for the relevant changes as follows:

Then, I simply push it using jj git push --bookmark <bookmark>. In this way, by creating changes intended for the remote on a branch separate from private changes, I can manage them so that private changes are not pushed. However, there is still a risk that private changes could be pushed to the remote due to operational error.

jj configurations

To prevent pushing private changes to the remote, git.private-commits 3 is useful. This option takes a revset. Any changes matching this revset will be blocked from being pushed.

revset-aliases

To identify changes that should not be pushed to the remote, I use revset aliases.

I manage the types of changes based on prefixes in the description (commit message) such as "private:", "wip:", "todo:", "mega", etc. Here, I treat changes whose description starts with "private" as private changes. To support this rule, I create a revset alias as follows:

[revset-aliases]
'private()' = 'description(glob:"private*")'

Evaluating private() lists only the changes with descriptions starting with "private", as intended.

In addition to private changes (private), I also define wip (work in progress), todo (incomplete), and mega (formal changes for Megamerge) as revset aliases.

[revset-aliases]
'wip()' = 'description(glob:"wip*")'
'todo()' = 'description(glob:"todo*")'
'private()' = 'description(glob:"private*")'
'mega()' = 'description(glob:"mega*")'
'local()' = 'wip() | todo() | private() | mega()'

I also define a local() revset alias to handle all of these together.

Using this revset alias in git.private-commits, it prevents the pushing of changes that match local() and their descendants.

[git]
private-commits = 'local()'

While git.private-commits is a robust way to prevent accidental pushes, it only handles the "what not to push" part. Sometimes, you want to perform additional checks—such as running a linter or a test suite—immediately before the push happens.

This is where a custom jj push alias becomes useful. Before I discovered the git.private-commits option, I relied entirely on this alias as my primary guardrail. Even now, I continue to use it to integrate tools like pre-commit into my workflow.

jj push alias

In the Jujutsu configuration file, you can set aliases 4. You can assign jj subcommands and arguments to any alias. Additionally, using the jj util exec subcommand 5, you can execute arbitrary external commands via jj. By combining these, I created a jj push alias for pushing to the remote.

The functionality of jj push is as follows:

One minor drawback of Jujutsu is that it doesn't natively support Git hooks, such as pre-commit. Therefore, I have configured pre-commit to run within the jj push alias.

Essentially, it's no longer a "Pre commit" but rather a "Pre push" check. I've set it up so that if the pre-commit command fails, the push is stopped; otherwise, the push proceeds.

jj push Alias Example

Based on the above, I have configured the jj push alias as follows:

https://github.com/rinx/dotfiles/blob/d1033370a8cb2142a2c506d3131da5cb28633f6d/jjconfig.toml#L45-L81

[aliases]
push = [
  'util',
  'exec',
  '--',
  'bash',
  '-c',
  '''
## Run check if pre-commit is available
if type pre-commit >/dev/null 2>&1; then
    echo "==> Running pre-commit before push..."
    pre-commit run --all-files

    if [ $? -ne 0 ]; then
        echo "❌ pre-commit failed. Aborting push."
        exit 1
    fi
else
    echo "⚠️  pre-commit not found. Skipping checks."
fi

## Extract the target Revset from command line arguments. Use default `::@` if not specified
echo "==> Checking for forbidden markers in pushable commits..."
targets=$(echo "$@" | grep -oP '(?<=(-b|--bookmark|-c|--change|-r|--revision)\s)[^ ]+')
if [ -z "$targets" ]; then
  target_revset="::@"
else
  target_revset="::(${targets//$'\n'/|})"
fi
## Verify that no unintended changes are included in the push target
if [ -n "$(jj log -r "$target_revset & mutable() & local()" --no-graph --ignore-working-copy -T 'commit_id')" ]; then
    echo "❌ Commits to be pushed contain wip/todo/private/mega markers. Aborting push."
    exit 1
fi

## Execute push to remote
echo "==> Pushing changes to remote..."
echo "==> jj git push $@"
jj git push $@
''',
  '',
]

By preparing simple guardrails as aliases, you can use Jujutsu with more peace of mind. I encourage you to build the optimal workflow and aliases that suit your own usage.


This article was translated with AI assistance and reviewed by the author.