Skip to content

Instantly share code, notes, and snippets.

@guibou
Last active November 23, 2025 21:57
Show Gist options
  • Select an option

  • Save guibou/0a8731e10dffe4919c987fc7bc263aea to your computer and use it in GitHub Desktop.

Select an option

Save guibou/0a8731e10dffe4919c987fc7bc263aea to your computer and use it in GitHub Desktop.
Understanding jj default revset filter

The jj default revset filter, present(@) | ancestors(immutable_heads().., 2) | present(trunk()) had been mysterious for me for multiples hours.

And frankly, I don't like things to be mysterious, at least in computer sciences (if you are a book, be misterious, that's great. If you are a date, well, don't force it, that's annoying. If you are a computer science date, you should not be mysterious at all. I see you, USA YY-DD-MM !)

The default log revset

First, this function is kinda magic because it only shows what I'm working on

  • present(@) and present(trunk()) are obvious, they just mean that @ AND trunk() will always appear in the log. See final note as to why they may NOT be part of ancestors(immutable_heads().., 2) and hence are included explicitly.
  • Now, what is the meaning of ancestors(immutable_heads().., 2). ancestors(x, 2) was simple, if we have a set of revision x, we get 2 parents, this adds a bit of context.

However, immutable_heads().. was confusing for me.

First, immutable_heads() is defined as present(trunk()) | tags() | untracked_remote_bookmarks().

It list a few derivations which are considered as immutable, including tags, the trunk and untracked_remote_bookmarks. The logic about untracked_remote_bookmarks is that if you do not track a remote "branch" or "bookmark", well, you do not really care about them and do not want to change them by mistake.

The .. operator

Most of my confusion comes from the properties of .. operator.

x.. means all the descendant of x which are not x.

edit: this is wrong actually. x..y means all the ancestors of y which are not ancestors of x. However x.. is a shortcut for x..visible_heads(), so that's all the ancestor of visible heads (I guess it is everything?) which are not ancestors of x.

So actually:

A-B-C
\-D

B.. is {C, D} and it includes D because D is a visible_head() and not an ancestor of B.

I've added this edit after the fact, so maybe the rest of the document is wrong / confusing with respect to this point.

edit: actually I discuss that later in the document, but a few days after, my brain had forgot this information. Either I'm stupid, or that's confusing (or both).

When applied on a unique revset, there is no confusion. For example:

A - B - C - D
\       \ - K
 \- E - F - G

Here, A.. is actually {B, C, D, K, E, F, G}. Similarly, C.. is {D, K}.

My mistake

However, what is happening when it is applied on multiples revisions.

My understanding was wrong. Actually, I thought that x.. would distribute on all the revision in x. Said otherwise, I thought that, which is WRONG, (a | b).. = a.. | b...

Let's assume the following tree:

A* - B - C* - D
\        \ - K
 \- E - F - G*
 |- K* - L

We have a few commit which are considered as immutable_heads(), {A, C, G, K}. See the * besides their name.

Remember the semantic of .. with a unique commit:

  • G.. = {}
  • K.. = {L}
  • A.. = {B, C, D, K, E, F,G}
  • C.. = {D, K}

Because I thought that .. was distributed on the sub revisions, ake, WRONG (G | K | A | C).. = (G.. | K.. | A.. | C..), my understanding had been that immutable_heads().. would be {D, B, C, K, L, E, F, G}, which is actually the complete tree.

The confusion increases

Then I saw the following commit:

https://github.com/jj-vcs/jj/commit/ea3a574e36ece4a3772765ef6fcc81094915a068

which adds untracked_remote_bookmarks() into immutable_heads(). And the changelog was including:

  • The default immutable_heads() set now includes untracked_remote_branches() with the assumption that untracked branches aren't managed by you. Therefore, untracked branches are no longer displayed in jj log by default.

And here I was super confused. How can adding MORE revision in immutable_heads() would actually remove revision from jj log output?

Even more confusion, and a revelation

What does the documentation for .. says?

  • x..: Revisions that are not ancestors of x. Shorthand for x..visible_heads().

Note that this piece of documentation is not precise enough from my point of view, because the revisions are NOT ancestors of x, but x is an ancestors of these revisions. This is way more clear with the documentation of ..:

  • x..y: Ancestors of y that are not also ancestors of x. Equivalent to ::y ~ ::x. This is what git log calls x..y (i.e. the same as we call it).

This is still confusing for me. Imagine the following repo:

A - B - X - Z
\ - C - Y

X..Y would be the ancestors of Y, including Y but excluding X, hence {A, C, Y}. And A being an ancestor of X, it is removed, So X..Y should actually gives {C, Y}, which is completely confusing for me, and actually, that's how it works.

Revelation: And actually, that's great. Because imagine now that X is main@origin (or whatever name you gave to trunk) and Y is another branch, well, if Y was not rebased on X, still, [email protected] will give you all the commit until the fork point, so e.g. everything you want to include in your rebase.

The correct behavior of ..

So the revisions included in x..y are not ancestors of x and are ancestors of y (including itself). That's the only thing you need to know. The .. is misleading by giving the idea of a path between both, but actually, there is no path.

So picking the previous example:

A* - B - C* - D
\        \ - K
 \- E - F - G*
 |- K* - L

If we pick (A | C | G | K).., let's analyse a bit more. First, it is a shorthand for (A | C | G | K)..visible_heads().

What are our visible_heads(), well, {D, K, G, L}

Now what are the ancestors of {D, K, G, L} which are NOT ancestors of (A | C | G | K). Well, it is {D, K, L}. For example, B is not included, because that's an ancestor of C.

My initial confusion was that adding revset inside x in x.. would lead to more childrens being evaluated. However the logic is in the reverse direction, each revision added to x excludes itself and its ancestors from the listing.

Conclusion on ancestors(immutable_heads().., 2)

So immutable_heads().. includes all the commit which are after what is considered an immutable head (so tag, remote untracked revision, and trunk) at the condition that they are not themself ancestors of one such immutable head.

So in summary, that's all your change, including bookmarked ones, which are NOT considered as immutable. Now you may ask, why not in that case using mutable() and I actually don't know.

mutable() is defined as ~immutable() and immutable is ::(immutable_heads() | root()). If you remove the root(), that's technically ::immutable_heads().

So mutable() = ~(::immutable_heads()) which smells like immutable_heads().. and I will admit that I'm just unsure about the precise semantic here, or what does the additionnal root() change into the equation, this would be for a future, already too long, discussion.

About present(@) and present(trunk())

@ and trunk() may not be part of your mutable changsets, however:

  • it can be convenient to have trunk() in your log, just to know how it is compared to your current commits.
  • it is not recommended, and jj will certainly complain, but hey, after all, do what you want, to jj edit an immutable commit. (jj will complain and request --ignore-immutable). However, if you do that, you'll be happy to have the log showing where @ is.

Conclusion

I'm not sure what to write here. Let's ask an AI.

@anacrolix
Copy link

This was a great read

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment