It's a small thing, but while jj adds some convenience, it doesn't add enough (for me) to offset the inconvenience of changing my workflow to not use WSL. (Another relatively minor inconvenience is its inability to use your SSH configs. So if you have multiple key pairs and need to use specific ones that aren't jj's default pick, an ssh-agent is the only way.)
That said, I would 100% recommend jj over git for any new programmer who hasn't yet had to contort their brain already into the git ways. All the things that git's UI does a great job of obscuring and presenting in a confusing way, jj presents in a straightforward way that makes sense and is easy to remember.
> Another relatively minor inconvenience is its inability to use your SSH configs.
This should be much better as of last week's release, when you can say "please use git as a subprocess rather than libgit2/gix".
> The autocrlf bit is a bit of a bummer on Windows, too.
autocrlf is always a bit of mess, to be fair, even when using git. There's never a single good setting because some projects do, for whatever reason, use CRLF for their line endings. (I recently spent 15+ minutes going through my git config and editor config and carefully making sense of things, trying to see why there were spurious line ending changes in my commit, before realizing this project I was contributing to used a mix of LF and CRLF endings in different files!)
I understand the decision here from an SSH-support situation, but doesn't this feel like a bit of a step backwards?
I came from a world of Mercurial, and I would love to be able to commit very often, and then be able to squash all those commits into a single commit. I feel git rebase does that, but I haven't been able to truly grok how to do that without running the possibility of completely destroying all changes I've made. I can't lose a giant feature (which is what I generally build) that may take an entire week to build, because I used the wrong git rebase command. I would love to be able to change an individual file and commit it to compare against new changes, but then pull all of these temporary changes/commits and merge/squash them all into a single commit, in case I need to rollback everything due to some breaking update.
A --- B --- C <- [main]
\
X --- Y --- Z <- [feature]
into this: A --- B --- C <- [main]
\
X --- Y --- Z' <- [feature]
\
Z
The old commit is not destroyed, just taken off the path you walk from "feature" back in time. Even if you `rebase -i main` on "feature" and drop Y and Z, they'll still be around, just not in your branch: A --- B --- C <- [main]
\ \
\ X' <- [feature]
\
X --- Y --- Z
If you're worried about rebase going bad, before you start, create a temporary branch (git checkout -b before-risky-rebase; git checkout -) to mark that line of history where "feature" points at the good state. A --- B --- C <- [main]
\
X --- Y --- Z <- [feature,before]
If anything goes wrong that `rebase --abort` doesn't fix, get out of there somehow and `git checkout before-risky-rebase`, or, on "feature," `git reset --hard before-risky-rebase`. Here, the backup branch "before" is still what "feature" was before the rebase: A --- B --- C <- [main]
\ \
\ X' --- (bad) --- (oops) <- [feature]
\
X --- Y --- Z <- [before]
As long as you don't force-push anything, it doesn't really matter if you damage the now-broken branch even more while getting out. Reset "feature" to your backup and it never happened. You can even damage "main" and still `reset --hard` to origin/main (if you have one) or the tip "main" had before you broke it.Even if you don't remember to create the backup branch, the hashes of your old commits now bypassed are still in the reflog. You can always find the hash where "feature" used to point and manually move the pointer back:
git checkout feature
git reflog
(find the hash)
git reset <hash> # --hard or --mixed if needed
Not that this is obvious or trivial or anything. It shouldn't be this hard. But your commits are safe from almost any way you might destroy them once they're somewhere in your history, at least until unreachable commits are eventually garbage-collected.I now have a bit of a new strategy:
- When starting up a branch, right up the "final commit message", along with a bunch of notes on what I think I need and other TODOs
- While working, when I want to checkpoint some work, I use jj split to chop up some chunk of work, describe it, and then edit up my TODOs
this way the tip of my branch is always this WIP commit describing my end goal, I can find it, and I can add a bookmark to it.
Instead of git "I add changes over time and make my commit graph move forward", it's "I have a final commit node and add commits _behind it_". Been working well enough.
I'm still experimenting with things, but I think my overall takeaway is that in jj my working copy is "on branch", so I should lean into that rather than try to emulate my older workflows too much. And this new workflow... I just find it better!
I do end up with descriptionless bookmarks that won’t push without a flag. So, I’m still doing something wrong.
But it’s already saved me a few times this week during some gnarly refactoring and merges.
It's never been wrong, but I am slightly unconvinced at how well merge conflict resolution works relative to git + rerere.
One suggestion - reverse the direction of the arrows: q->r rather than q<-r
Unfortunately the arrows are kind of confusing regardless of which way they go. You're suggesting they point forward in time, from the old commit to the new commit. The way they're drawn is the direction of the reference: a commit points at its parent. The argument in favor of each way the arrows could go feel about equally strong to me, and my understanding is that the convention in repo diagrams is for arrows to go in the direction of the reference, so that's what I went with.
There's plenty about how you can use jj as a replacement but no clear guidance on what upstream commits will actually look like if you use it.
- No support for LFS
- No support for hooks (precommit, etc)
- No? Bad? support for submodules
- No? Bad? support for line ending styles
If you don't care about those, you _should_ be able to use jj completely "undetected". It does encourage rewriting history more than some git workflows like.
In terms of issues in the git repo -- there shouldn't be any. jj uses git as a backend, all commits are stored as git commits, etc. If you colocate the repo, you're able to use git commands directly.
jj is kind of hard to really explain because a bunch of the design decisions have subtle but important impacts on other decisions, so your first impression of a feature may be slightly wrong because you don't get the implications yet.
jj is sort of the same as git: you have a DAG of snapshots of your project. The differences are in how you interact with those things. To try and put it in git terms:
1. Commits are mutable, not immutable (but we'll talk about is more later)
2. You're always working in the context of some commit
3. When you modify a file, it becomes part of that commit (we'll talk about the index in a minute)
4. You don't need to care about branches at all, the "detached head" state is the nrom.
5. commits are immutable in the "immutable data structures" sense, in that whenever you modify them, it's almost like you're adding a commit to them. this is why jj calls its "commits" "changes", change IDs stay stable as you edit them, and they produce new git commits for every edit.
6. Because of how this all fits together, you don't need an explicit index; if you want one, you can just `jj new` twice to get two changes on top of each other, and then edit your files. When you have what you want, `jj squash` will move the diff into the parent commit, and now it's "part of that feature" or whatever. If you want `git add -p`, that's `jj squash -i`.
That is kind of it on some level, but in reality, it's kind of hard to convey how a few, smaller, more orthogonal primitives let you do everything you can do in git, but easier. (I tried to actively think of cases last night and only came up with two or three that were easier in git than jj, and jj will have fixes for most of those soonish.)
stashing is another great example of a feature of git that's just a workflow pattern in jj.
There's just... it's a lot. It's hard to know what the best thing really is. Other than `jj undo` :)
(I've got this on the brain since I am literally working on my tutorial right now)
> it's kind of hard to convey how a few, smaller, more orthogonal primitives let you do everything you can do in git, but easier
Some of this didn't really click for me until I experienced it (and I'm still very much learning). The one that sticks out is how you're always in a commit. Where in git you work in "modes" – editing in the index, rebasing, committing, etc. In jj you're always "stable" and can do anything from that point.
The way this is sold is things like "mutable commits" or "first class conflicts", but for me the real power was just realising that I can always move to another commit/change without having to pre-plan how to do that, always being able to edit my commit message right now without having to finish up something else first. Now going back to git feels like the tool is slowing me down and not keeping up with the pace and style I want to work in. I was surprised that this was the thing I most enjoy, because it's a little hard to motivate.
Shouldn't "edits" be attached to the arrows rather than the nodes in the graphs? So not r [edit3] --> q [edit2] --> p [edit1] but r --[edit3]--> q --[edit2]--> p --[edit1]--> o, where o is p's predecessor. (I think you could do without "edit1" here.)
And then "jj abandon q", if I'm understanding it right, turns r --[edit3]--> q --[edit2]--> p into r --[edit3]--> p.
(I am not certain I've understood the official docs for "jj abandon" correctly, so it's very possible that I'm wrong about what it does, in which case obviously the above is wrong. But whatever it does, if you're distinguishing "files" from "edits", surely the "edits" go on the edges rather than the nodes of the revision-graph.)
> if I'm understanding it right, turns r --[edit3]--> q --[edit2]--> p into r --[edit3]--> p.
You are right with the outcome but wrong about why. `jj abandon -r q` would turn `r --> q --> p` into `r --> p`, but you're passing the node as the argument (r is for revision) not the edge.
Hilariously, I am literally working on writing version 2 of my tutorial right now, and I'm literally talking about `jj abandon`. What do you think about this? It cuts off where I literally am right now: https://gist.github.com/steveklabnik/71165f9ff5e13b1e95902c4...
> You are right with the outcome but wrong about why. `jj abandon -r q` would turn `r --> q --> p` into `r --> p`
Well, it can do two things. Given: `r(f3) --[e3]--> q(f2) --[e2]--> p(f1)`
`jj abandon -r q` makes `r(f1+e3) --[e3]--> p(f1)`, as if you had rebased `r` onto `p`.
`jj abandon -r q --restore-descendants` makes `r(f3) --[e2+e3]--> p(f1)`, as if you had squashed `r` into `q`.
I like the idea of putting the edits on the arrows, but there are a couple senses in which the edit is associated with the change itself rather than an edge between two changes:
1. A change with two parents starts out by merging them, and then it can make edits on top of that merge. If the edits go on an edge instead of on a node, which of the two edges do those changes belong on?
2. If you move a change (e.g. by rebasing it), its diff comes with it. I guess you could say that when you rebase, you're not moving just the node but also the edge from it to its parent?
Even so, diffs on edges feels very right. I may update that diagram.
EDIT: Updated!
I believe that `jj squash` and `jj backout` also operate on edges rather than nodes, but the examples here don't make it clear. `jj squash` ought to combine the edge `r -> q` with the edge for `q -> parent(q)` (not depicted) and ultimately leave the `r -> q` edge as "empty", and `jj backout` ought to create an edge that has the inverse diff of another edge (which, in this case, is indistinguishable from `s`'s node changing to be equivalent to `q`'s node).
My impression is that the main motivation behind jj is that Google realized how difficult and costly it is to train all new hires in their internal tooling but did not want to open-source it completely. So they came up with a thin UI layer, made it workable with git as a backend and published it in the hope it will catch on.
Google has a basically tolerable and pretty easy-to-learn Mercurial-based frontend for its bizarre legacy Perforce system.
Everything about JJ screams to me that it's been created as the passion project of someone who really wants to build a better VCS, making it compatible with Git was necessary to give it a chance of adoption, and making it compatible with Piper (Google's Perforce thing) was a way to get it funded as a potential benefit to Google.
Top-down Google engineering would never produce a project like this IMO.
I think it's unfair to call it a "thin UI layer". My own project git-branchless https://github.com/arxanas/git-branchless might more legitimately be called a "thin UI layer", since it really is a wrapper around Git.
jj involves features like first-class conflicts, which are actually fairly hard to backport to Git in a useful way. But the presence of first-class conflicts also converts certain workflows from "untenable" to "usable".
Another comment also points out that it was originally a side-project, rather than a top-down Google mandate.
Maybe I'm in the minority, but I like fixing conflicts as I go. What am I missing?
See https://jj-vcs.github.io/jj/latest/conflicts/.
> If I rebase and then fix a conflict later, are those conflict markers going to appear in some of the commits on github when I push?
We error out if you try to push conflicts because the Git remote would probably not know how to interpret them. We will probably add an option to allow it later because it can be useful to be able to share conflicts with others if you know that they're also using jj.
> Maybe I'm in the minority, but I like fixing conflicts as I go. What am I missing?
You can still do that. Hopefully the above answers your question.
Honestly, this page doesn't really make a compelling case as to how checking out the commit with a conflict and amending is better than git rebase/whatever --continue. Overall, it's also quite abstract. Concrete examples would be deeply appreciated.
If I mostly worked on a small-to-medium size project with close connections between developers, where mostly you just get your code merged pretty quickly or drop it, then I wouldn't see any value in it. But for Linux kernel work git can often be pretty tiresome, even if it never actually gets in the way.
I thought that nothing could beat Git until I tried Fig (Google's Mercurial thing). It ends up being awful coz it's so bloody slow, but it convinced me that a more advanced model of the history can potentially life easier more often than it makes it harder.
Fig's model differs from Git in totally different ways than jj's does but just abstractly it showed me that VCS could be meaningfully improved.
At the end of the day, every DVCS is ultimately "here is a repository that is a bunch of snapshots of your working directory and a graph of those snapshots" plus tools to work with the graph, including tools to speak to other repositories.
From any given snapshot A -> B, both git and jj can get you there. The question is, which tools are more effective for getting the work done that you want to do?
`git commit --fixup=X foo; git stash; git rebase -i X^; git stash pop`
`jj squash --into X foo`
git commit --fixup X ; git rebase --interactive --autostash --autosquash X^
If you do that often, an alias might help; I have one for the second command above. You might want to look at git-fixup or git-absorb for automatically finding the "X" commit.
Aside: I really ought to try jj, it looks very promising.
Sort of. It has both changes and commits, actually. (and sometimes commits are called revisions.)
jj log
@ muzrswxs steve@steveklabnik.com 2025-02-12 10:23:11 85b41b31
│ (empty) (no description set)
○ wotxrwpp steve@steveklabnik.com 2025-02-12 10:23:09 24ce0a16
│ (empty) (no description set)
○ ztxxskuu steve@steveklabnik.com 2025-02-11 18:20:56 1b3e12ac
│ run sqlx migrate as part of deploy process
◆ qwovsnvt steve@steveklabnik.com 2025-02-11 17:45:24 trunk b224ca8b
│ <redacted>
Okay, so muzrswxs is a change ID. It's true that we're connecting changes in a graph, and that that forms history. So in that sense, it's like a git commit. But because changes are mutable (well, the ○ ones and @ are, the ◆ there is not), they are implemented as a sequence of commits. So if you look on the far right there, you'll see 85b41b31 and then below it, 24ce0a16. Below that, 1b3e12ac. These are commit IDs.The first two changes are empty, so what happens if we modify a file?
jj log
@ muzrswxs steve@steveklabnik.com 2025-02-12 10:28:18 404a73b1
│ (no description set)
○ wotxrwpp steve@steveklabnik.com 2025-02-12 10:23:09 24ce0a16
│ (empty) (no description set)
○ ztxxskuu steve@steveklabnik.com 2025-02-11 18:20:56 1b3e12ac
│ run sqlx migrate as part of deploy process
◆ qwovsnvt steve@steveklabnik.com 2025-02-11 17:45:24 trunk b224ca8b
│ <redacted>
Note that (empty) went away on that head change there, and its change ID is still muzrswxs. But the commit ID has changed from 85b41b31 to 404a73b1. None of the parents changed, of course.We can even take a look at this history:
jj evolog --summary
@ muzrswxs steve@steveklabnik.com 2025-02-12 10:28:18 404a73b1
│ (no description set)
│ M README.md
○ muzrswxs hidden steve@steveklabnik.com 2025-02-12 10:23:11 85b41b31
(empty) (no description set)
The evolution log will show us how our change has evolved over time: first we had 85b41b31, then we modified README.d and now we're at 404a73b1.> I find when you dig into people's understanding of git (or version control in general), a lot of them understand it as storing a sequence of diffs. This small thing breaks their understanding of the whole system.
I agree with you in some sense, but also, kinda don't. That is, I agree that thinking git stores diffs is not correct, but I'm not fully sold on how big of a deal it is to be incorrect here. And once you really get into things, like, how packfiles are implemented, diffs are present.
> Calling them "changes" seems like it would reinforce this belief. Or is that the idea? Does jj embrace this perhaps more intuitive "sequence of diffs" view, but more successfully hide the "sequence of commits" reality?
I can assure you that these names are a heated kind of debate internally. I actually said two days ago "hey, so we have changes, commits, and then revision as a synonym for commit. shouldn't commit be a synonym for revision? because 'revision' is kind of an abstract idea, but 'commit' is git-specific, so like, I think it should be "we have changes, and changes have revisions, but the git backend implements revisions as commits" and that thread is still going this morning, with links to many previous discussions. Someone even wrote a blog post a year ago https://blog.waleedkhan.name/patch-terminology/
jj is still figuring out how best to present its ideas. I really like "change and revision" to describe these two things, but a lot of folks are concerned that "change" is too generic and is hard to figure out, that is, when I said this above
> its change ID is still muzrswxs. But the commit ID has changed
This is two different uses of the word "change". Is that confusing? Maybe. Is it confusing enough to find another word? Not sure.
It's not so much about what actually happens underneath, that should be irrelevant and git just does what it does for practical reasons ultimately (as you point out, with packfiles, but this is definitely not a detail any git user needs to be aware of).
The problem I see is that git actually exposes both views of things. A seasoned git user will be used to the "duality" of commits vs diffs (ie. they are two different views of the same thing). Git exposes diffs directly when cherry-picking or rebasing, but at most other times you are working with commits. You don't push/pull diffs, you push/pull commits. It seems like a small thing, but every time I've dug into why somebody is having trouble with git it seems to be they view the world only as diffs.
So my question really was whether jj attempts to expose only one or the other. Looking at your explanation I would say it doesn't. It seems to me like changes are very similar to branches in git. At least this is how I think of branches in git, but I tend to be the "git guy" in every place I've worked. I mutate branches all day long by doing git commit amend etc.
It seems like the real point here is to get rid of "branch" as that is an overloaded concept and split it into two things: change and bookmark. In many ways it just seems like a reinforcement of the way I (and I guess other "git guys") use git anyway. Interesting!
Large file handling needs to be sane in any new VCS, IMHO, as this is a main failing of git (..without the extra legwork of git-lfs).
Edit: https://github.com/jj-vcs/jj/issues/80 Could maybe bring jj up to parity with git here
Git's underlying storage format just isn't a very good fit for any kind of "large-ish file" storage; Git LFS is mostly just hack and it is unlikely to be supported anytime soon. Our hands are a bit tied on that front.
My impression is that most of the interest and momentum for solving the "large files problem" would preferably be invested in a native non-Git backend for Jujutsu.
Maybe something like this?
``` for patch_file in "$@"; do jj new
patch -p1 < "$patch_file"
author=$(extract_author "$patch_file")
commit_message=$(extract_commit_message "$patch_file")
jj describe -m "$commit_message" --author "$author"
done
``` jj rebase -r 'mine() & diff_contains("TODO")' -d 'heads(@::)'
in any reasonable number of commands, which will1) find all of the commits I authored with the string `TODO` in the diff
2) extract them from their locations in the commit graph
3) rebase all non-matching descendant commits onto their nearest unaffected ancestor commits
4) find the most recent work I'm doing on the current branch (topologically, the descendant commit of the current commit which has no descendants of its own, called a "head")
5) rebase all extracted commits onto that commit, while preserving the relative topological order and moving any branches.
Furthermore, any merge conflicts produced during the process are stored for later resolution; I don't have to resolve them until I'm ready to do so. (That kind of VCS workflow is not useful for some people, but it's incredibly useful for me.)
1. Can I use jj inside a repo that was already initialized with git? I think the answer is yes, but I haven't found a tl;dr for it.
2. What does the workflow look like to use jj on an existing git repo that all of your coworkers use git for?
Yes.
> What does the workflow look like to use jj on an existing git repo that all of your coworkers use git for?
I struggle a little to answer this because on some level, the answer is "whatever you'd like it to be." That is, from your co-workers' perspective, nothing changes. You push some changes up to a git repo, they have no clue you used jj to do it. But I also feel like that maybe isn't answering your question.
1. init jj in an existing git repo
2. instead of branching, do x, y, z
3. instead of committing after changes are done, do x, y, z
4. when pushing, do x, y, z
5. if someone else pushes to the same branch, here's how to handle it
6. if someone rebases and force pushes the branch, here's how to handle it
7. if you have merge conflicts, here's how to handle that
I think I'm having a hard time trying to grok the jj "mental model" while simultaneously understanding how it translates to an existing git repo.
I suspect for jj to get traction outside of single devs or companies that use jj exclusively, some extra focus in the docs giving guidance in the liminal space between would be super helpful.