Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: docs/design: git branch mode #4670

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

BatmanAoD
Copy link
Collaborator

@BatmanAoD BatmanAoD commented Oct 18, 2024

This evolved from a discussion in Discord.

Rendered

Diagrams (aka very rough sketches)

TBD:

@BatmanAoD
Copy link
Collaborator Author

I specifically did not draw a diagram of the result of applying backout, because the behavior won't be simple or obvious with or without this configuration option until #2802 is resolved.

Comment on lines +12 to +13
`.git/HEAD` (hereafter `HEAD`) is populated with a valid `jj` bookmark, and the
bookmark is currently at `@` or `@-`. In this design, the bookmark will be
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, we currently don't allow HEAD to point to @. At the start of the command, we import HEAD and create a new working-copy commit on top of it if it has changed since last time.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting; I assumed jj edit <bookmark> would set HEAD to @.

For a merge commit <m>, how does jj edit <m> decide which parent to assign to .git/HEAD? Is it arbitrary?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably the first parent, as far as I can remember. So, pretty close to being arbitrary.

Comment on lines +54 to +64
When in branch mode, these operations preserve branch mode, and do not change
which bookmark `HEAD` points to:

```
rebase
abandon
commit
new [without specifying revisions]
duplicate
backout
```
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of these commands currently do anything special w.r.t. branches - it's all generic behavior that's implemented at a lower level. I'd really like to keep it that way. That ensures that the behavior is consistent between commands, including for our custom commands at Google, and in operations implemented by various UIs that use the library.

So, can you instead describe the behavior in a way that doesn't refer to a specific command (other than as an example)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try to rephrase, but the short version is that these commits aren't doing anything "special." If you've got an active branch, then that branch stays active, and the corresponding jj bookmark moves appropriately, i.e. it moves together with @.

Comment on lines +3 to +5
This is a proposed configuration option to replace
`experimental-advance-branches`. It is not intended to replace or obviate
topics.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would help me if this document started with a problem statement. Can you list some scenarios that you think we should improve? Here's one I can think of where it should be easy to make jj behave better:

$ jj
@  n [email protected] 2024-10-19 17:22:54 70e302a0
│  (no description set)
○  w [email protected] 2024-10-19 17:05:55 main HEAD@git 873ebde8
│  (empty) (no description set)
○  u [email protected] 2024-10-19 17:05:47 52b0bdfd
│  (empty) (no description set)
◆  z root() 00000000

$ git branch
* main

$ jj desc w -m blah
Rebased 1 descendant commits
Working copy now at: n e270b828 (no description set)
Parent commit      : w a316f16d main | (empty) blah

$ git branch
* (HEAD detached at a316f16)
  main

I.e. we left Git in a "detached HEAD" state where we could have left main checked out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since describe doesn't move @ (it changes the commit ID but not the change ID), it also wouldn't change .git/HEAD in "active branch" mode, since branch mode means that .git/HEAD contains a branch reference, not a commit sha.

(As before, I will add this to the doc, I just wanted to briefly answer your question now.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since describe doesn't move @ (it changes the commit ID but not the change ID), it also wouldn't change .git/HEAD in "active branch" mode, since branch mode means that .git/HEAD contains a branch reference, not a commit sha.

(As before, I will add this to the doc, I just wanted to briefly answer your question now.)

But the Git branch that HEAD@git points to always points to a commit hash, not a change_id, so it does need updating. Or, in terms of bookmarks, the JJ bookmark might still point to the same change_id, but as a newly created commit is associated with the change_id from the bookmark, and the old commit becomes hidden, JJ effectively needs to run jj git export to update the commit hash that git sees for the branch that the bookmark is tracking.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposal is currently only for colocated repos. In colocated repos, the export already happens automatically:

Jujutsu will import and export from and to the Git repo on every jj command automatically.

I'll need to learn a little more about how non-colocated repos currently behave before adding them to the design.


These Excalidraw diagrams show the effect of various `jj` commands when in
branch mode:
https://excalidraw.com/#json=pkmTWTuzkToGdc4xSUy2C,7uhU9FBI6rJK-U_XGCH4HA
Copy link
Collaborator

@ilyagr ilyagr Oct 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly adding on to #4670 (comment) (which is the kind of questions these diagrams are probably meant to address), I found these diagrams hard to follow. They are very pretty, but they need more words telling the reader where to look, and what is most important about the pictures.

Another (more minor, I mostly figured it out but it could be clearer) thing I found confusing at a glance is what the red and the blue arrows mean. After looking at the text, they are meant to be bookmarks (you should label that in the picture!), but it's unclear how they are related to each other. I guess the red one is supposed to be the "current bookmark"?

I think the pictures could be very helpful once you make thing clearer. (Though if presenting a few clear examples as Martin asked is easier in other ways, that's more important). Here's one possible way to arrange them:

- Starting state
- A bit of text introducing a few examples, probably 
     `new` is the most important one
- A few examples (the pictures)
- A bit of text introducing a few more examples
- A few more examples

and so on.


Another suggestion for the pictures: it would be interesting what happens if there are two bookmarks pointing to Z, only one of which is "current". You don't have to show it in every picture, though, or perhaps it could be in a separate picture.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, are the "split" and "new" diagrams switched?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your "ambiguous update: abandon Z example, I thing jj would currently create a branch conflict with the red branch being conflicted between pointing at D and at Y. The thing you suggested wouldn't work if we continue to disallow HEAD pointing to @ (as we currently do).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood; I intentionally left the diagrams in Excalidraw rather than rendering SVGs so they could be iterated on. I'll certainly add labels and/or a legend to the diagram itself.

The basic jist is that the top left diagram is an "initial state", and then almost all the other diagrams show what the next state would be after executing the command shown from that initial state.

The branch arrows are unrelated; red happens to be the "current" branch in the initial state. The thing showing that it's "current", though, is the shading and coloring on the pointed-to commit.

Agreed the two-bookmarks example would be good to show; that's actually one of the primary things I struggle with using the current advance-branches feature.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, split and new are not switched, but the use of new as a node label is probably not helpful. split is similar to new in that both introduce a new commit on top of the target commit. (The docs even mention that if the target commit is empty, split is prohibited because it would be identical to new.)

One thing that may not be clear is that the letters/words/symbols in the commit nodes are change IDs, and @ itself is shown as a commit. The command is always shown on the upper-left of each diagram. So there are two variants of split next to each other, one with no arguments and one with the argument @-. The commit-node that says new is just to indicate that a new commit has been created, but looking again, there's no good reason to use Z' in one split diagram and new in the other. I think the thing I need to do with those is explicitly state that the content of the original Z commit has been split between Z' and Z (on the left) and between Z and new (on the right).

Copy link
Collaborator

@ilyagr ilyagr Oct 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, split and new are not switched,

It's then confusing to me how in the new example, N becomes a child of "new". Perhaps that's a mistake? If it is intentional, what's the logic behind it?

I think I was indeed mistaken about "split". It was partially because it is hard for me to not think of the highlighted commit as the working copy, it looks more important than the commit actually marked @, so I find myself getting them confused.

I think the thing I need to do with those is explicitly state that the content of the original Z commit has been split between Z' and Z (on the left) and between Z and new (on the right).

Yes, something like this would probably be helpful.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yes, you're right, I think the new diagram is indeed incorrectly copy-pasted from split @-. Thank you. The N should still be a child of Z, as in split without args.

...speaking of args, I also forgot that the positional args of split are paths, not refs. So that should be split -r @- and split (without -r).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


**Note:** The only part of this design that is specific to `git` colocation is
the use of `.git/HEAD`. The design could be implemented for any backend that
has a concept of a "current" branch.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that if we make this work, it should work on non-colocated repos as well. To me, this looks like a proposal of "what if we added the notion of a 'current branch' to jj?". TBH, I'm not sure I'm excited about this -- I'd probably want this to be optional and to keep it off by default -- but it is probably worth thinking through, and my first impression might be wrong.

This makes more sense to me than the experimental advance-branches feature in some ways, but it would also be a much larger change to implement. E.g., you'd need to adjust the log template to show which branch is current, store this information in the repo, etc.

Copy link
Collaborator

@ilyagr ilyagr Oct 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also something to be said for fixing the advance-branches feature, if it's possible without storing extra info (which I'm not at all sure about).

I think your original idea was to store the extra info in the Git repo only (by storing a branch in the HEAD ref), but I'm suspicious about this part of it. We probably want it to work with undo, and so it must be stored on the jj side as well.

@ilyagr
Copy link
Collaborator

ilyagr commented Oct 20, 2024

Thank you for the writeup!

@tim-janik
Copy link
Contributor

Writeup Comments / Confusion

Commenting on some parts of the writeup:

These commands always enter branch mode, by setting .git/HEAD appropriately:
bookmark set @
bookmark create @-

I don't see how that could work without an extra option:

bookmark create foo @-
bookmark create bar @-
# What if I want HEAD@git->foo, but still create a non-advancing `bar`
# Git equivalent:
git checkout -B foo HEAD
git branch bar

It gets worse if @ would be supported in addition of @- (which would mean that HEAD@git points to @ via indirection, i.e. the working copy, something JJ currently never does), but see below.

When in branch mode, these operations preserve branch mode, and do not change which bookmark HEAD points to:
new [without specifying revisions]
[...]

Uhm, starting to list commands and the various modes they operate in (sometimes @ is updated, sometimes not) looks like it'd be very error prone. And definitely hard to model by a user (think cognitive load).

In a similar vein, your Changing branches, Merges, Diffing sections also sound like too much magic to me.
Read "magic" as: Special rules of JJ operation behind the scenes, that are hard for the majority of users to keep in mind all the time

In particular having @ as auto-advance branch tip is something I don't fully understand.
My guess is that this would not work for a colocated repo at all - after all HEAD@git is always a valid commit ID in a non-empty repo (the Git basis for the index and the JJ basis for the working copy @) and the tip of the history as far as git is concerned - think of git commit how it advances in detached-head mode.
Related, jj edit 'root()' does not work and tracked bookmarks cannot point to root() either, because '@' and 'root()' are JJ concepts but not valid Git commit hashes.

Take that all with a grain of salt, I don't know the JJ code itself. I am merely pointing out that after several months of JJ use, I still have a hard time wrapping my head around how any of the advance-branch proposals that I have seen so far could work in practice. And therefore, I wonder how newcomers would be expected to model that behavior...

@tim-janik
Copy link
Contributor

Branch Interoperability

Note, one operation that already makes JJ update HEAD@git point to a Git ref, just a missing one, is this:

⮞ jj new 'root()' && jj log --no-graph -T builtin_log_oneline 
Working copy now at: upqzzzok 3acbda69 (empty) (no description set)
Parent commit      : zzzzzzzz 00000000 (empty) (no description set)
upqzzzok timj 2024-10-22 03:07:01 3acbda69 (empty) (no description set)
zzzzzzzz root() 00000000
⮞ cat .git/HEAD 
ref: refs/jj/root
⮞ cat .git/refs/jj/root
cat: .git/refs/jj/root: No such file or directory

That could just as well be a non-existing refs/heads/master, as is the case with a newly initialized Git repo.
Interestingly, this is the one case where jj log does not show a HEAD@git ref.

Considering that JJ will have to work for people with colocated repos for many, many years to come, I think its interoperability with Git branches should be improved.

That means, make JJ properly update HEAD@git, i.e. without going into detached-head mode unless Git already is in detached-head mode.

The one, comparatively simple thing, that I think JJ should do to improve Git interoperability is this:

  • All places that currently update HEAD@git should check if HEAD@git contains a Git branch ref.
  • If so, write the commit hash into the Git branch ref instead of .git/HEAD and keep the ref pointer in .git/HEAD.
  • If JJ has a bookmark, that tracks the Git ref that was just written to, do jj git import <git-head-bookmark>

That is it. For a start.
The way to enter the "advance-branch-mode" could be git checkout -B somebranch as long as there is no new JJ command for it.
Note that JJ already deals with external commands writing a ref into .git/HEAD when it snapshots, this would just allow it to leave the branch name in .git/HEAD which is quite important when Git repos are shared with other tools, e.g. the Gollum Wiki, or tools operating on a gh-pages branch, etc. Just look for any other tool that operates on a Git repo and you will find that in most cases it will expect a repo that is not in detached-head mode (and has at least 1 commit).

It is really nothing more than, this:

  • Imagine a user that will issue the following command after every JJ operation that modified the repository:
    git checkout -B master HEAD (or any other branch name)
    The user could be having that in their shell prompt...
  • The above user would just always create 2 undo steps instead of one:
    @  4abd6660b3dff8ff timj 1 second ago import git refs args: jj --no-pager st
    ○  6b50b1756c3fb8fc timj 2 second ago new empty commit args: jj new
    
    Otheriwse it'd behave like any other colocated JJ repo.

So I am asking, are there any negative side effects to be expected in a JJ repository where the user runs git checkout -B master HEAD after every JJ command?
Or should such a repository operate normally?
This would just have the added side effect that all other Git-aware programs suddenly know how to deal with the repo and additionally there is a single JJ bookmark that auto-updates...

The Auto-Advance Conundrum

Wether a user needs a branch tip to be advance on DVCS-commit or DVCS-new/checkout/switch depends on the use case and more importantly the mental model the user applies at that moment.
This mental model is vital information that JJ lacks and Git has available.

In this aspect, JJ is at a disadvantage compared to Git because:

  • Git pro: Git knows what branch the user mentally means to advance, it's found in .git/HEAD, so it can make that easy.
  • Git con: When .git/HEAD is in detached mode, commits are still possible, but a bunch of other things are hard or impossible (rebase).
  • JJ pro: Naturally operating in detached HEAD mode has caused JJ to make things easy for users in this mode, rebasing, ancestry commit editing etc all work properly and are easy.
  • JJ con: Forcing JJ into branch mode is cumbersome (needed e.g. because you are editing a gitit repo). Not only do you have to manually advance the current master/gh-pages/wiki branch after every JJ command, you also must make sure .git/HEAD points to this one branch again to make any other Git related tool behave. (Depends, most tools always look at .git/HEAD, but some might look only at a particular branch like gh-pages or are configurable like Gollum).

The thing is, JJ doesn't have to be at a disadvantage compared to Git here. The needed information about the user intent (and mental model) is readily available in .git/HEAD. JJ should just not throw it away, instead make as much good use of it as Git does by adding support to advance the ref that .git/HEAD points to.
Also, make it easy for users to communicate their intent but do not try to second guess it - the circumstances if and which branch needs advancing change and need to be communicated by users. Instead, provide simple commands to switch between branches and detached-head mode, ideas:

$ jj head                                 # Inspect branch-tip vs detached-head mode
Detached HEAD mode.
Working copy at: rwvmpwzp aba47a45 (empty) (no description set)
Parent commit      : rtlqvrtv a3f650ff (empty) (no description set)
$ jj head foo                             # like: git checkout -B foo
Advancing branch mode, branch: foo
Working copy at: rwvmpwzp aba47a45 (empty) (no description set)
Parent commit      : rtlqvrtv a3f650ff foo | (empty) (no description set)
$ jj head -                               # like: git checkout --detach HEAD
Detached HEAD mode.
Working copy at: rwvmpwzp aba47a45 (empty) (no description set)
Parent commit      : rtlqvrtv a3f650ff foo | (empty) (no description set)

Native JJ Repositories

JJ repos that are not colocated could decide if they "simulate" an internal HEAD@git config setting or not.
The needs are definitely not as pressing in this case, because there are no other external tools for which interoperability needs to be preserved. However there is one external factor that is important: The users mental model.
For users that have come to expect .git/HEAD updates to properly support ref updates by using colocated repos, it could help to offer the same behavior in case something like a command like `jj head`` would be introduced.

docs/design/branch-mode.md Outdated Show resolved Hide resolved
@kriomant
Copy link

This design proposes keeping git branches in-sync with jj at any given time. If it's possible to do, it's great!
But in the meantime it may be easier to implement command which establishes git branch state temporarily.

My usecase:

  • I am totally fine with jj detached-head mode most of the time
  • But I miss some tools which expect git to have current branch, notably "cargo release"

It would be just fine for me to run something like: jj git exec cargo release, where jj git exec:

  • sets current git branch to something
  • calls tool
  • absorbs new commits
  • returns to detached mode

@BatmanAoD
Copy link
Collaborator Author

@kriomant I think you can just write a shell function to do what you want; for instance, in Bash:

with_branch() {
    git checkout --no-guess $1
    shift
    "$@"
    jj git import
    git checkout $(git rev-parse HEAD)
}

Usage would be something like with_branch main cargo release.

The final git checkout is probably not necessary if you don't actually need jj to be immediately back in headless mode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants