conceptually [commits] are snapshots, but concretely they are stored as deltas
This is not exactly true. If you create a new commit with git commit, then the commit object, the tree it points to, and any (new) objects pointed to from the tree are stored as complete, loose objects in the file system, under .git/objects in the repository. So a commit is a snapshot, concretely.
If you later run git gc, or if you push the new objects to a remote, then those loose objects will be put into a packfile, and inside the packfile they might be stored as deltas against other objects, as an optimization.
I’ve been using jj pervasively recently and I think my favorite part is the plain confidence that comes from switching between branches when files are always tracked. I can be editing one commit, realize something about the code that recontextualizes a completely separate branch, and then just stop, jj edit the other branch immediately, and come right back. The important part is that never once in this flow do I have to think about “what about my current changes?” because they’re always saved. Always.
This workflow astounded me two months ago, but now I do this branch switching daily. Never having to think about git stash ever again is so empowering.
It’s a bit ironic, I’ve tried jujutsu before (when it was probably a bit less stable) and somehow couldn’t wrap my head around it that well. Went back to git almost immediately.
I think your comment about not thinking about current changes just made it “click” a bit for me :)
I’ve long ago started telling other people how bad the git interface is and somehow I wanted jj to just be a better UI, when it is a (hopefully) better workflow - with a better UI.
I gonna have to give it another go down the road…
Never having to think about git stash ever again is so empowering.
That sounds incredible. One of the worst parts about git is managing worktrees, or stashes, or multiple repo copies entirely (for when things get really hairy or critical).
Despite using git for many years, I never developed a full mental model of it. Like, my mental model covered 98% of all git interactions, but the remaining 2% were a doozy. In contrast, despite knowing like 4 jj commands and sometimes forgetting what they’re called, I’m able to confidently handle more complicated situations with jj than I can with git.
I’ve had this experience just a couple times before (where a tool with a simpler model outshines a tool with a more complex model that I have many years of experience with): Emacs->Vim, LaTex->Typst, and now git->jj. Come to think of it, Bash really feels like it ought to be on the left hand side of one of those arrows, though I mostly just avoid it so I haven’t learned alternatives.
IMO (as a helix/typst/jj user) the apt right-hand for bash is fish. It has a simplifying-yet-empowering feel to me very similar to how jj does about git; feeling very confident with a uniform shell (particularly re: variables, escaping, functions, the range of built-ins) is a really nice feeling that’d escaped me forever, too.
One thing I’ve come to realize makes shell/bash hard is that it is a grab bag of state, and there are all these inconsistent commands to inspect that state, like:
Print global options:
$ set -o |head -n 2
allexport off
braceexpand on
Print variables and functions:
$ set |head -n 2
AGENT_VARS_FILE=/home/andy/.ssh/agent-vars.sh
BASH=/bin/bash
Do you want to figure out if a variable is an environment variable (exported), or read-only?
What about external commands and builtins? (there doesn’t appear to be a way to enumerate all of them)
$ type -a true
true is a shell builtin
true is /usr/bin/true
true is /bin/true
aliases:
$ alias -p | head -n 2
alias gitd='git diff'
alias grep='grep --color=auto'
There are also
traps with trap -p
these magic variables like $PS1 $RANDOM $HOME which live in the same namespace.
the variables behave differently – some of them are for configuration, and some like LANG= even make system calls!
So yeah all of that is hard to remember. And the syntax doesn’t correspond with the semantics
I definitely didn’t know it before working on Oils
The upcoming Oils release organizes a lot of this into a single object model / namespaces, so you can inspect the state of the shell with a single command.
The idea is to make it more like Python, where you can use dir() and getattr() on anything, and kinda navigate the state of the interpreter, without reading the manual.
A big part of that is pretty printing, with the = operator ;-)
ysh-0.24.0$ = {k: 42}
(Dict) {k: 42}
ysh-0.24.0$ proc p { echo hi }
ysh-0.24.0$ = p
<Proc 0xb50>
You should be able to pretty print the entire state of the shell uniformly, as well as choosing which parts to print.
But we’re not done with this – it may take awhile to put everything in namespaces and make it uniform, with reflection.
It’s also true there are other parts of bash/shell to learn, like there are essentially 5 different process constructs:
ls - external
ls | wc -l - pipelines are very special cases
( subshell ) - people confuse this syntax for grouping
background & - somewhat weird syntax
$(date) - command sub
(and bash also has process sub). YSH also rationalizes those a bit
I call those “exterior” constructs and the latter “interior”
One thing I’ve also noticed confuses people is that ls is external, but cd is internal, but there is no syntactic difference. It is not very visible or discoverable
Regarding resolving merge conflicts with VS Code, there’s a built-in vscode configuration for the merge editor, although I haven’t tried it personally. (I’m assuming that the author was referring to the no-configuration setup rather than having explicitly tried the vscode setting.)
There are two settings (diff-editor and merge-editor), and when I set them both to vscode I eventually ended up in some state where the editor was confused showing empty temporary files. Unfortunately I wasn’t sure what I was doing so I’m not sure how to reconstruct it.
I can’t say I’ve followed jj much (I’ve noticed articles about it posted here and on the orange site though).
A quick perusal of the readme, which calls out some apparently significant milestones and the changes they involve, makes me think this is re-inventing some of the early mistakes git made.
Git received a lot of flack for mashing too much functionality into git checkout: change to a branch? change to a new branch? checkout some files, possibly from a different branch?
Git now provides more specific actions to users via git switch and git restore.
For some reason jjstarted with convenience aliases for change branch (jj checkout) and “merge changes into branch” (jj merge), and has now removed them, so that a number of operations that are wildly different from a user perspective, are all just different invocations of jj new:
jj new - seems to have the same end effect as git commit.
jj new some-branch - seems to have the same end effect as git checkout some-branch or git switch some-branch
jj new some-branch @ seems to have the same end effect as git merge some-branch
I’m open to using different models, I use both Mercurial and Git regularly. But removing convenience aliases just sounds like madness, and makes me less inclined to try this.
jj new always creates a new change. The arguments you pass determine where this change is created:
no arguments: on top of @
with an argument: on top of that change
with multiple arguments: on top of all of those changes, aka, a merge
They’re not wildly different: it’s always “create a new change.” The arguments control which parents this new changes has, but that’s it.
You’re not wrong that from a git perspective this could involve multiple commands, but that’s because git has more concepts than jj does, and so needs more commands to express the same things.
I think the idea behind removing ‘git convenience’ aliases is to guide the user into the proper mental model. All those invocations of new are identical (Well, the first is short for jj new @). It creates a new change with the specified parents. I do think that unifying ‘normal’ and ‘merge’ commits into one kind of object is a useful simplification.
Personally I think building this simpler mental model is valuable. I can do basically everything I can imagine doing with a git history with 3 commands: jj new, jj rebase, jj squash.
I felt the same way when I first tried it, but I now think my sibling comment is right.
In the post I mentioned that thinking about things in terms of “what this does in Git” ends up making things harder to understand, much as if you tried to learn Git in terms of what each command does in svn.
The problem I have with this idea of “forget what git does, this is jj” is the fact that you can’t clone a “native” jj repo. You’re inherently using it to work with a git repo, so the idea that you can just treat it as something inherently different, is wrong IMO.
I might be noted that jj abstracts over its storage backend and Google uses its own internally. So there is a clean separation between the jj model and the git storage.
If you look at the git-specific commands jj does have, they’re pretty much all related to interacting with remotes: clone, export, fetch, import, init, push, remote. Actually, three of these commands don’t interact with a remote: export, import and init. export and import are only used when you have a weird setup where you point your jj repo to a git repo in a completely different location. init just initializes a local repo with git as the selected storage backend. That’s it… all other commands very nicely match jj’s own model of version control.
Even when it comes to storage, jj stores additional information on top of what git knows about. Change IDs are the most obvious example. That’s just another indicator that “alternative git frontend” is selling jj short.
That argument proves too much. With that reasoning, you could complain that when learning how to use SQLite to create and update a single-file database, you have to learn what byte values SQLite writes to the file when you modify the database, because the database file is inherently a series of bytes.
In that hypothetical, I would object that you should think of your single-file database in terms of tables, rows, indexes, and so on. While you could open the file in your hex editor and then edit data by tweaking individual bytes, you shouldn’t generally need to do that. Learning how to edit a row in your hex editor would be a distraction from learning more useful and powerful SQLite concepts like SQL UPDATE statements or foreign key constraints.
By analogy, you should also be able to use jj without caring about the details of the files it stores within the .git directory or which Git commands would result in the same changes to those files.
That argument proves too much. With that reasoning, you could complain that when learning how to use SQLite to create and update a single-file database, you have to learn what byte values SQLite writes to the file when you modify the database in various ways, because the database file is inherently a series of bytes.
SQLite doesn’t require you to interact with the underlying file using byte values at all.
JJ contains an entire section of “git” commands, some of which are mandatory if you want to use the tool in any remotely useful way.
Only if you want to communicate with remote git servers. At first blush this may seem stupid (because everyone does), but it nicely points out exactly where the true interop boundary is, i.e. git as represented on the wire. You can push commits, branches, tags. You can’t push an index. The jj git commands are only necessary inasmuch as you’re actively translating for a git repo, and it’s very predictable how it works. Yes, you’re using it to work with a Git repo. But obviously you’re going to have to learn a different way of authoring (what may end up being those same commits) if there’s to be anything new worth learning at all?
I agree that all abstractions can be a bit leaky, and it’s good to at least have some idea what might be happening under the hood. That said, at least in my experience with jj, I’ve not run into any major leaks yet. Yes, when using the explicit jj git ... commands, you’re a bit more exposed to git concepts like branches and things like that, but even then it’s rarely so important to understand exactly how the git parts will connect to the jj parts.
In practice, I just think in terms of the Jujutsu concepts and don’t think at all about what git instructions/changes will occur behind the scenes, and this works really well for me.
I think the key difference is, in jj these are the one command because they are actually the one operation in terms of its model, and that model turns out to be very easy to reason about.
I think a lot of this is to do with the mental model. In Git, a branch is a real thing - you can check out a branch that points to a commit, and you can check out a commit, and these are two different things that result in different states and will produce different effects when you try and commit or make further changes. Therefore, the CLI needs to differentiate these things for clarity, including using different commands.
In Jujutsu, though, a commit is a commit is a commit. A bookmark is nothing more than a link to a commit with some associated metadata about upstreams. And jj new creates a new commit that follows on from the commit(s) that you pass it. It doesn’t matter if you give it that commit as a bookmark, as a commit ID, as a relative commit, or even as a git hash - as long as it can uniquely identify a commit, it can create a new commit based off that and check it out. Even jj new by itself is doing the same thing, but with the implicit argument of “the current commit” (i.e. create a new commit with the parent as the current commit).
The merge version is possibly a bit cleverer - I’ve not actually used it like that before, as I mostly merge via Github/lab/etc, and rebase locally. In principle it’s doing the same thing, but with multiple commits instead of one. But I’m more used to a merge commit being its own thing, so it feels a bit funky to create a new commit from multiple commits like that.
This is more of a problem with reconciling fundamental differences in the Git/Mercurial vs jj workflows.
Of course, jj new is only responsible for creating a new commit (and materializing it in the working copy by default). The problem is that when you’re trying to imitate the Git/Mercurial workflows, then you end up creating a bunch of new commits to represent the staging area and/or working copy, so it seems like jj new is doing everything.
An alternative “edit workflow”, used by many, might have these equivalencies for your cases:
git commit -> jj describe
git switch -> jj edit
git merge -> jj new
However, the edit workflow distinguishes between ‘starting’ and ‘finishing’ work in a way that Git/Mercurial don’t, so it’s not a perfect equivalence.
Maybe someone in here can help answer my question.
I really like working with stacked diffs. At my company, we’ve recently taken up Graphite, which offers a great workflow around stacked diffs, while being completely compatible with git and GitHub. In particular, it allows a great reviewing experience, where each “commit” gets its own branch on GitHub, which you can comment on, update and so on. Graphite then takes care of stacking them, merging them in the right order, changing the order around as desired, etc.
I get that jj offers some of the same benefits around working with stacked diffs, but I’m having a hard time understanding how people use jj to collaborate with others. Do you still just create old-fashioned PRs with lots of commits in them? How do you review these, especially if a commit in the “middle” of a PR changes? Does using jj in a team effectively require you to adopt Gerrit or something like that?
Edit: Graphite has a bunch of marketing fluff about AI features. You can safely ignore that and just use it for the great PR reviewing and stacking features it has.
For use with github, the idea is to create multiple stacked PRs, same as graphite does. However, the forge integrations in jj are still WIP, so people either do it manually, use a script, or some external tooling for now.
I see, that’s what I expected. I really like how easy Graphite makes it to create new stacked PRs and even reorder them or restack them while they’re being worked on. I also don’t mind doing it manually, but I think it would be an unnecessary burden to put on my colleagues.
From a Git perspective, jj is very “rebasey”. Editing a file is like a git commit –amend, and in the “fix a typo” move above the edit implictly rebases any downstream commits.
That sounds extremely elegant and terrifying at the same time. With git, I often mess around on old commits and then decide to abandon my attempt by using “git checkout” or “git rebase –abort” and know that I will get back to a pristine working copy of the upstream repo.
Similarly, just doing “git checkout” to switch to a different branch I might have some local changes I forgot about. Having those implicitly auto-committed sounds rather dangerous to me. How does jj prevent accidentally pushing work-in-progress changes?
A common flow is to jj new COMMIT (which puts you on a new commit descending from COMMIT). You can squash changes back into the parent interactively, which means you’ve essentially DIYed your own “index”. Except, if you instead decide you want this set of working changes to be its own commit, you can just leave it in the new one. Maybe you then rebase this commit (e.g. jj rebase -r @ -B xyz where xyz is the commit following your target in the main line), to put this as a new commit in after. Or whatever, you can throw it around anywhere, squash it, leave it. The unit is a commit.
edit to add: also, you can just jj op undo if something didn’t work! All snapshots are saved, so you can rewind to any point you ran any jj command, including just status, etc.
edit to elaborate: those local changes, then, would be on their own commit. You wouldn’t accidentally push it, because it’s clearly a WIP commit. You probably haven’t given it its final description yet, there’s other random stuff in there you don’t intend to push. Bookmarks (for git tracking purposes) don’t get moved automatically; you explicitly do it. It turns out this works well.
One thing I didn’t learn until using jj in anger on a shared repo with lots of branches and frequent committers, is that leaf commits (w/o an associated bookmark) are automatically ‘suspect’ in jj, because you don’t only “checkout a branch” — you jj new cool-branch@origin instead. You’re now on an empty commit (and the UI marks empty commits in many places for good reason), and if you move off it without making any changes, it automatically vanishes (gets abandoned).
If you happen to make some local changes first, maybe you play around with something, then those are of course saved in that new commit until you explicitly drop them. If you check something else out and then want to return, unlike git I don’t look at the branch list, I jj log and it shows everything relevant, including my leaf commit on top of a commit that has cool-branch@origin next to its ID. So I see it and go, right, there’s my old WIP or whatever, I’ll edit that, or new that, or whatever. If I later jj git fetch and that cool-branch@origin has moved, I’ll still see my old WIP commit poking out of the branch halfway, and I can rebase it on top of the new branch head and then edit it. (You can do it the other way around, like git, as well — edit it, and then rebase it — but isn’t it cool that the working copy truly isn’t privileged in this way? You don’t have to focus the Eye of Sauron on a commit to rebase it. Heck, you can rebase multiple disparate commits with a revset all at once.)
Sounds pretty cool. I’d like to try jj but I’m still sort of stuck with git due to relying so heavily on magit and wouldn’t want to lose it. I barely interact with git directly on the CLI myself anyway. But of course magit is still strongly tied to the git workflow and mindset.
I never got the hang of magit even though I code in emacs all day. I’ve also gotten tired of using the CLI with git and had a lot of my own keybindings setup in .emacs. For jj, I didn’t want to drop back into the CLI which is why I wrote jj-fzf. For my purposes, it is more visual than magit and I made sure all hotkeys to execute jj commands are discoverable by default. You can take a peek at it in the screencasts here: https://github.com/tim-janik/jj-fzf?tab=readme-ov-file
As a data point, I needed magit because git UX is clunky and slow, with jj everything is so streamlined I dont feel I miss magit that much. Id still like to have it, but it has been fine because the amount of time I spend with the jj cli va the git cli is a lot less. Give it a try! I had the same reluctance to use it due to magit.
With git, I often mess around on old commits and then decide to abandon my attempt by using “git checkout” or “git rebase –abort” and know that I will get back to a pristine working copy of the upstream repo.
It’s interesting to note that Git doesn’t have a facility to undo that undo operation, while jj does:
In Git, you can’t say “actually, I want to resume that rebase I started” and jump back directly into what you were doing.
In jj, all of the intermediate rebase states are just commits, so there’s no secret extra state (like the .git/rebase-merge directory) that tracks the rebase.
In jj, you can use jj undo to undo an arbitrary operation.
So jj should actually be safer once you get used to it.
Similarly, just doing “git checkout” to switch to a different branch I might have some local changes I forgot about. Having those implicitly auto-committed sounds rather dangerous to me. How does jj prevent accidentally pushing work-in-progress changes?
There are a few default mechanisms:
When creating a new commit, jj won’t automatically “advance” the branch pointing to its parent commit. Many commits are therefore not addressable by branches by default, so you wouldn’t easily be able to accidentally push them.
jj git push will refuse to push commits without a description, and jj new will create commits without descriptions by default.
Here’s a dumb question that no tutorial has answered for me yet (and I suppose I could just do some tests and figure this out):
All of the docs and tutorials for Jujutsu say there is no working copy, that you run jj new, then edit your files, and presto, those edits are in the top-most commit. All of your edits are automatically in the top-most commit.
Is this just conceptual, or really happening? What specific process is doing this? When does this happen? If I edit a file in the editor of my choice, is there a process that got spawned that is running in the background that automatically updates the jj internal state? Does jj create this commit only if I run a jj command after the edit?
Here’s a concrete specific example:
Case A: Let’s say I have a jj/git colocated repo. I run jj new. Then I edit a file. Then immediately I use git reset --hard <commit> to jump to a different commit in the tree. Did I lose my edit? Does jj know about my edit?
Case B: Same circumstance, but I run jj st before git reset --hard <commit>. Do I lose my edit then?
By default, jj does a snapshot every time you run a jj command. However, you can configure it to use watchman, and it will then be able to snapshot based on file system changes.
Case A, you lose your edit. Case B, you don’t. It takes a snapshot of the working copy on every invocation. You’ll find mention of this in the docs in a few places, and can also configure this behavior.
I’d have to go through a lot of ceremony to use JJ at work - on the other hand - think I actually understand all of that, unlike with git, where I find conflicts, merging and rebasing confusing after all these years still.
(Most recently I had the displeasure of signing some commits I had accidentally committed in a vm without my signing key, and then pushed.)
I went through the same thought process about 6 months ago (i’ve been using jj exclusively since) and this part took no more than an hour or two to become muscle memory.
Nitpicking:
This is not exactly true. If you create a new commit with
git commit, then the commit object, the tree it points to, and any (new) objects pointed to from the tree are stored as complete, loose objects in the file system, under.git/objectsin the repository. So a commit is a snapshot, concretely.If you later run
git gc, or if you push the new objects to a remote, then those loose objects will be put into a packfile, and inside the packfile they might be stored as deltas against other objects, as an optimization.And explicitly not necessarily as deltas against the parent commit or the same file in a different commit at all.
Kudos, well said.
I’ve been using jj pervasively recently and I think my favorite part is the plain confidence that comes from switching between branches when files are always tracked. I can be editing one commit, realize something about the code that recontextualizes a completely separate branch, and then just stop,
jj editthe other branch immediately, and come right back. The important part is that never once in this flow do I have to think about “what about my current changes?” because they’re always saved. Always.This workflow astounded me two months ago, but now I do this branch switching daily. Never having to think about
git stashever again is so empowering.(Posted to the wrong subject first lol)
It’s a bit ironic, I’ve tried jujutsu before (when it was probably a bit less stable) and somehow couldn’t wrap my head around it that well. Went back to git almost immediately. I think your comment about not thinking about current changes just made it “click” a bit for me :) I’ve long ago started telling other people how bad the git interface is and somehow I wanted jj to just be a better UI, when it is a (hopefully) better workflow - with a better UI. I gonna have to give it another go down the road…
That sounds incredible. One of the worst parts about git is managing worktrees, or stashes, or multiple repo copies entirely (for when things get really hairy or critical).
Despite using git for many years, I never developed a full mental model of it. Like, my mental model covered 98% of all git interactions, but the remaining 2% were a doozy. In contrast, despite knowing like 4 jj commands and sometimes forgetting what they’re called, I’m able to confidently handle more complicated situations with jj than I can with git.
I’ve had this experience just a couple times before (where a tool with a simpler model outshines a tool with a more complex model that I have many years of experience with): Emacs->Vim, LaTex->Typst, and now git->jj. Come to think of it, Bash really feels like it ought to be on the left hand side of one of those arrows, though I mostly just avoid it so I haven’t learned alternatives.
IMO (as a helix/typst/jj user) the apt right-hand for bash is fish. It has a simplifying-yet-empowering feel to me very similar to how jj does about git; feeling very confident with a uniform shell (particularly re: variables, escaping, functions, the range of built-ins) is a really nice feeling that’d escaped me forever, too.
Have you tried fish yet? I’ve found it pretty close to that for me
Was going to say the same thing. Love fish. My shell journey (which began decades ago): csh-> tcsh -> zsh -> fish.
One thing I’ve come to realize makes shell/bash hard is that it is a grab bag of state, and there are all these inconsistent commands to inspect that state, like:
Print global options:
Print variables and functions:
Do you want to figure out if a variable is an environment variable (exported), or read-only?
Another way to print functions
What about external commands and builtins? (there doesn’t appear to be a way to enumerate all of them)
aliases:
There are also
trap -p$PS1 $RANDOM $HOMEwhich live in the same namespace.LANG=even make system calls!So yeah all of that is hard to remember. And the syntax doesn’t correspond with the semantics
I definitely didn’t know it before working on Oils
The upcoming Oils release organizes a lot of this into a single object model / namespaces, so you can inspect the state of the shell with a single command.
The idea is to make it more like Python, where you can use
dir()andgetattr()on anything, and kinda navigate the state of the interpreter, without reading the manual.A big part of that is pretty printing, with the
=operator ;-)You should be able to pretty print the entire state of the shell uniformly, as well as choosing which parts to print.
But we’re not done with this – it may take awhile to put everything in namespaces and make it uniform, with reflection.
It’s also true there are other parts of bash/shell to learn, like there are essentially 5 different process constructs:
ls- externalls | wc -l- pipelines are very special cases( subshell )- people confuse this syntax for groupingbackground &- somewhat weird syntax$(date)- command sub(and bash also has process sub). YSH also rationalizes those a bit
I call those “exterior” constructs and the latter “interior”
One thing I’ve also noticed confuses people is that
lsis external, butcdis internal, but there is no syntactic difference. It is not very visible or discoverableRegarding resolving merge conflicts with VS Code, there’s a built-in
vscodeconfiguration for the merge editor, although I haven’t tried it personally. (I’m assuming that the author was referring to the no-configuration setup rather than having explicitly tried thevscodesetting.)There are two settings (
diff-editorandmerge-editor), and when I set them both tovscodeI eventually ended up in some state where the editor was confused showing empty temporary files. Unfortunately I wasn’t sure what I was doing so I’m not sure how to reconstruct it.I can’t say I’ve followed
jjmuch (I’ve noticed articles about it posted here and on the orange site though).A quick perusal of the readme, which calls out some apparently significant milestones and the changes they involve, makes me think this is re-inventing some of the early mistakes git made.
Git received a lot of flack for mashing too much functionality into
git checkout: change to a branch? change to a new branch? checkout some files, possibly from a different branch?Git now provides more specific actions to users via
git switchandgit restore.For some reason
jjstarted with convenience aliases for change branch (jj checkout) and “merge changes into branch” (jj merge), and has now removed them, so that a number of operations that are wildly different from a user perspective, are all just different invocations ofjj new:jj new- seems to have the same end effect asgit commit.jj new some-branch- seems to have the same end effect asgit checkout some-branchorgit switch some-branchjj new some-branch @seems to have the same end effect asgit merge some-branchI’m open to using different models, I use both Mercurial and Git regularly. But removing convenience aliases just sounds like madness, and makes me less inclined to try this.
jj newalways creates a new change. The arguments you pass determine where this change is created:@They’re not wildly different: it’s always “create a new change.” The arguments control which parents this new changes has, but that’s it.
You’re not wrong that from a git perspective this could involve multiple commands, but that’s because git has more concepts than jj does, and so needs more commands to express the same things.
I think the idea behind removing ‘git convenience’ aliases is to guide the user into the proper mental model. All those invocations of new are identical (Well, the first is short for
jj new @). It creates a new change with the specified parents. I do think that unifying ‘normal’ and ‘merge’ commits into one kind of object is a useful simplification.Personally I think building this simpler mental model is valuable. I can do basically everything I can imagine doing with a git history with 3 commands:
jj new,jj rebase,jj squash.[Comment removed by author]
I felt the same way when I first tried it, but I now think my sibling comment is right.
In the post I mentioned that thinking about things in terms of “what this does in Git” ends up making things harder to understand, much as if you tried to learn Git in terms of what each command does in svn.
The problem I have with this idea of “forget what git does, this is jj” is the fact that you can’t clone a “native” jj repo. You’re inherently using it to work with a git repo, so the idea that you can just treat it as something inherently different, is wrong IMO.
I might be noted that jj abstracts over its storage backend and Google uses its own internally. So there is a clean separation between the jj model and the git storage.
If you look at the git-specific commands jj does have, they’re pretty much all related to interacting with remotes: clone, export, fetch, import, init, push, remote. Actually, three of these commands don’t interact with a remote: export, import and init. export and import are only used when you have a weird setup where you point your jj repo to a git repo in a completely different location. init just initializes a local repo with git as the selected storage backend. That’s it… all other commands very nicely match jj’s own model of version control.
Even when it comes to storage, jj stores additional information on top of what git knows about. Change IDs are the most obvious example. That’s just another indicator that “alternative git frontend” is selling jj short.
That argument proves too much. With that reasoning, you could complain that when learning how to use SQLite to create and update a single-file database, you have to learn what byte values SQLite writes to the file when you modify the database, because the database file is inherently a series of bytes.
In that hypothetical, I would object that you should think of your single-file database in terms of tables, rows, indexes, and so on. While you could open the file in your hex editor and then edit data by tweaking individual bytes, you shouldn’t generally need to do that. Learning how to edit a row in your hex editor would be a distraction from learning more useful and powerful SQLite concepts like SQL
UPDATEstatements or foreign key constraints.By analogy, you should also be able to use
jjwithout caring about the details of the files it stores within the.gitdirectory or which Git commands would result in the same changes to those files.SQLite doesn’t require you to interact with the underlying file using byte values at all.
JJ contains an entire section of “git” commands, some of which are mandatory if you want to use the tool in any remotely useful way.
Only if you want to communicate with remote git servers. At first blush this may seem stupid (because everyone does), but it nicely points out exactly where the true interop boundary is, i.e. git as represented on the wire. You can push commits, branches, tags. You can’t push an index. The jj git commands are only necessary inasmuch as you’re actively translating for a git repo, and it’s very predictable how it works. Yes, you’re using it to work with a Git repo. But obviously you’re going to have to learn a different way of authoring (what may end up being those same commits) if there’s to be anything new worth learning at all?
I agree that all abstractions can be a bit leaky, and it’s good to at least have some idea what might be happening under the hood. That said, at least in my experience with jj, I’ve not run into any major leaks yet. Yes, when using the explicit
jj git ...commands, you’re a bit more exposed to git concepts like branches and things like that, but even then it’s rarely so important to understand exactly how the git parts will connect to the jj parts.In practice, I just think in terms of the Jujutsu concepts and don’t think at all about what git instructions/changes will occur behind the scenes, and this works really well for me.
I think the key difference is, in jj these are the one command because they are actually the one operation in terms of its model, and that model turns out to be very easy to reason about.
I think a lot of this is to do with the mental model. In Git, a branch is a real thing - you can check out a branch that points to a commit, and you can check out a commit, and these are two different things that result in different states and will produce different effects when you try and commit or make further changes. Therefore, the CLI needs to differentiate these things for clarity, including using different commands.
In Jujutsu, though, a commit is a commit is a commit. A bookmark is nothing more than a link to a commit with some associated metadata about upstreams. And
jj newcreates a new commit that follows on from the commit(s) that you pass it. It doesn’t matter if you give it that commit as a bookmark, as a commit ID, as a relative commit, or even as a git hash - as long as it can uniquely identify a commit, it can create a new commit based off that and check it out. Evenjj newby itself is doing the same thing, but with the implicit argument of “the current commit” (i.e. create a new commit with the parent as the current commit).The merge version is possibly a bit cleverer - I’ve not actually used it like that before, as I mostly merge via Github/lab/etc, and rebase locally. In principle it’s doing the same thing, but with multiple commits instead of one. But I’m more used to a merge commit being its own thing, so it feels a bit funky to create a new commit from multiple commits like that.
This is more of a problem with reconciling fundamental differences in the Git/Mercurial vs jj workflows.
Of course,
jj newis only responsible for creating a new commit (and materializing it in the working copy by default). The problem is that when you’re trying to imitate the Git/Mercurial workflows, then you end up creating a bunch of new commits to represent the staging area and/or working copy, so it seems likejj newis doing everything.An alternative “
editworkflow”, used by many, might have these equivalencies for your cases:git commit->jj describegit switch->jj editgit merge->jj newHowever, the
editworkflow distinguishes between ‘starting’ and ‘finishing’ work in a way that Git/Mercurial don’t, so it’s not a perfect equivalence.Maybe someone in here can help answer my question.
I really like working with stacked diffs. At my company, we’ve recently taken up Graphite, which offers a great workflow around stacked diffs, while being completely compatible with git and GitHub. In particular, it allows a great reviewing experience, where each “commit” gets its own branch on GitHub, which you can comment on, update and so on. Graphite then takes care of stacking them, merging them in the right order, changing the order around as desired, etc.
I get that jj offers some of the same benefits around working with stacked diffs, but I’m having a hard time understanding how people use jj to collaborate with others. Do you still just create old-fashioned PRs with lots of commits in them? How do you review these, especially if a commit in the “middle” of a PR changes? Does using jj in a team effectively require you to adopt Gerrit or something like that?
Edit: Graphite has a bunch of marketing fluff about AI features. You can safely ignore that and just use it for the great PR reviewing and stacking features it has.
For use with github, the idea is to create multiple stacked PRs, same as graphite does. However, the forge integrations in jj are still WIP, so people either do it manually, use a script, or some external tooling for now.
I see, that’s what I expected. I really like how easy Graphite makes it to create new stacked PRs and even reorder them or restack them while they’re being worked on. I also don’t mind doing it manually, but I think it would be an unnecessary burden to put on my colleagues.
Edit: And thanks for explaining :-)
That sounds extremely elegant and terrifying at the same time. With git, I often mess around on old commits and then decide to abandon my attempt by using “git checkout” or “git rebase –abort” and know that I will get back to a pristine working copy of the upstream repo.
Similarly, just doing “git checkout” to switch to a different branch I might have some local changes I forgot about. Having those implicitly auto-committed sounds rather dangerous to me. How does
jjprevent accidentally pushing work-in-progress changes?A common flow is to
jj new COMMIT(which puts you on a new commit descending from COMMIT). You can squash changes back into the parent interactively, which means you’ve essentially DIYed your own “index”. Except, if you instead decide you want this set of working changes to be its own commit, you can just leave it in the new one. Maybe you then rebase this commit (e.g.jj rebase -r @ -B xyzwherexyzis the commit following your target in the main line), to put this as a new commit in after. Or whatever, you can throw it around anywhere, squash it, leave it. The unit is a commit.edit to add: also, you can just
jj op undoif something didn’t work! All snapshots are saved, so you can rewind to any point you ran anyjjcommand, including juststatus, etc.edit to elaborate: those local changes, then, would be on their own commit. You wouldn’t accidentally push it, because it’s clearly a WIP commit. You probably haven’t given it its final description yet, there’s other random stuff in there you don’t intend to push. Bookmarks (for git tracking purposes) don’t get moved automatically; you explicitly do it. It turns out this works well.
That makes sense, thanks!
One thing I didn’t learn until using jj in anger on a shared repo with lots of branches and frequent committers, is that leaf commits (w/o an associated bookmark) are automatically ‘suspect’ in jj, because you don’t only “checkout a branch” — you
jj new cool-branch@origininstead. You’re now on an empty commit (and the UI marks empty commits in many places for good reason), and if you move off it without making any changes, it automatically vanishes (gets abandoned).If you happen to make some local changes first, maybe you play around with something, then those are of course saved in that new commit until you explicitly drop them. If you check something else out and then want to return, unlike git I don’t look at the branch list, I
jj logand it shows everything relevant, including my leaf commit on top of a commit that hascool-branch@originnext to its ID. So I see it and go, right, there’s my old WIP or whatever, I’lleditthat, ornewthat, or whatever. If I laterjj git fetchand thatcool-branch@originhas moved, I’ll still see my old WIP commit poking out of the branch halfway, and I canrebaseit on top of the new branch head and theneditit. (You can do it the other way around, like git, as well —editit, and thenrebaseit — but isn’t it cool that the working copy truly isn’t privileged in this way? You don’t have to focus the Eye of Sauron on a commit to rebase it. Heck, you can rebase multiple disparate commits with a revset all at once.)Sounds pretty cool. I’d like to try
jjbut I’m still sort of stuck with git due to relying so heavily on magit and wouldn’t want to lose it. I barely interact withgitdirectly on the CLI myself anyway. But of course magit is still strongly tied to the git workflow and mindset.I never got the hang of magit even though I code in emacs all day. I’ve also gotten tired of using the CLI with git and had a lot of my own keybindings setup in .emacs. For jj, I didn’t want to drop back into the CLI which is why I wrote jj-fzf. For my purposes, it is more visual than magit and I made sure all hotkeys to execute jj commands are discoverable by default. You can take a peek at it in the screencasts here: https://github.com/tim-janik/jj-fzf?tab=readme-ov-file
Thanks, this looks cool!
As a data point, I needed magit because git UX is clunky and slow, with jj everything is so streamlined I dont feel I miss magit that much. Id still like to have it, but it has been fine because the amount of time I spend with the jj cli va the git cli is a lot less. Give it a try! I had the same reluctance to use it due to magit.
I feel you. I’m also waiting on something like magit for jj…
If that helps, I’ve created an implementation of the vc.el interface for jj, with an added sprinkle of project.el
https://codeberg.org/vifon/vc-jj.el
It’s interesting to note that Git doesn’t have a facility to undo that undo operation, while jj does:
.git/rebase-mergedirectory) that tracks the rebase.jj undoto undo an arbitrary operation.So jj should actually be safer once you get used to it.
There are a few default mechanisms:
jj git pushwill refuse to push commits without a description, andjj newwill create commits without descriptions by default.git.private-commitsconfiguration setting to configure your own set of commits that shouldn’t be pushed.Here’s a dumb question that no tutorial has answered for me yet (and I suppose I could just do some tests and figure this out):
All of the docs and tutorials for Jujutsu say there is no working copy, that you run
jj new, then edit your files, and presto, those edits are in the top-most commit. All of your edits are automatically in the top-most commit.Is this just conceptual, or really happening? What specific process is doing this? When does this happen? If I edit a file in the editor of my choice, is there a process that got spawned that is running in the background that automatically updates the jj internal state? Does jj create this commit only if I run a jj command after the edit?
Here’s a concrete specific example:
Case A: Let’s say I have a jj/git colocated repo. I run
jj new. Then I edit a file. Then immediately I usegit reset --hard <commit>to jump to a different commit in the tree. Did I lose my edit? Does jj know about my edit?Case B: Same circumstance, but I run
jj stbeforegit reset --hard <commit>. Do I lose my edit then?By default, jj does a snapshot every time you run a jj command. However, you can configure it to use watchman, and it will then be able to snapshot based on file system changes.
Case A, you lose your edit. Case B, you don’t. It takes a snapshot of the working copy on every invocation. You’ll find mention of this in the docs in a few places, and can also configure this behavior.
I’m intrigued. Judging by this:
https://martinvonz.github.io/jj/latest/github/
I’d have to go through a lot of ceremony to use JJ at work - on the other hand - think I actually understand all of that, unlike with git, where I find conflicts, merging and rebasing confusing after all these years still.
(Most recently I had the displeasure of signing some commits I had accidentally committed in a vm without my signing key, and then pushed.)
I went through the same thought process about 6 months ago (i’ve been using jj exclusively since) and this part took no more than an hour or two to become muscle memory.