Having worked at Meta for many years, my work habit is heavily shaped after that model. While many of these tools are open source and usable in public, going back to Git and GitHub turned out to still be a huge downgrade. I spent quite some time trying to recreate the workflow I had at Meta, and I want to share it here in case it is useful to you too.
Disclosure: I worked on the team behind Sapling for about 4 years.
I really like Sapling, but both JJ and Sapling can support Git interoperation out of the box. After being a full-time JJ user for a few months, I really do think it is a better choice between Sapling and JJ outside Meta.
For the record, I am not considering Git. Let’s just agree on the UX for Git is not even in the same league comparing to Sapling and JJ.
Whenever I use Sapling I spent a lot of time copying & pasting commit hashes. I have made a few attempts to optimize this part in Sapling (e.g. zsh auto complete with commit descriptions) but it never felt good enough. Sapling also gained the feature of fuzzy matching commit by title names, but that is still too many words comparing to what JJ has.
In JJ, each commit has two IDs — changeset ID and git commit ID. The changeset
ID will not change as you iterate on the commit, and unlike Sapling, when
calculating the shortest prefix to reference a commit, it only considers commits
shown in your jj log (a very lossy description). This means I can type very
few characters to reference my commits when referencing them. Yay!
The UX here is really great. JJ highlights the part you need to type to refer to a commit, and that’s typically one character long (occasionally two or three!).
Running jj rebase -r mm is so much faster than copy & paste.
In Git, you need to tell Git whenever you want to track a change. Either git add or git commit -a.
In Sapling, you need to tell Sapling “here is a file I want to be tracked with
source control” with sl add/remove (also sl addremove to just include
everything).
In JJ, everything is automatically tracked whenever you run a JJ command. You
will need to add files into .gitignore if you don’t want to see it. (I
realized I have never needed to figure out the answer to the question “how do I
tell JJ not track a file” before writing this)
I think after working professionally this many years, I very rarely needed to
pick what change I want to commit. I do edit commits so it is easier to get
reviewed, but I almost never do that when I was iterating on something. This
pretty much means I never really need to have the step “tell the VCS which
change to track”. Yet git is making git add / git commit -a requirement to
everyone’s workflow. Perhaps there is a use case, but I doubt it’s how the
mainstream works.
Even if I want to be selectively creating commits, both JJ and Sapling have
interactive mode that allows me to pick at line level on what I want to include
for each commit — jj commit --interactive and sl commit --interactive.
What even is git add -i? As far as I can remember Git came out long after
ed is obsolete, right?? Right??
As much as I miss my old team and coworkers, respectfully I think Meta’s incentive structure is not designed to motivate engineers to consider open source needs. As an outsider now, I cannot overlook this factor. It is hard to run an open source project while working at Meta.
Meta’s developer experience was great because almost everyone is on a path paved by many other talented engineers prior. There are many integrations to different systems (build system, CI/CD, code review, filesystem, …). These internal integrations will absolutely take priorities than supporting external use cases or open source projects. Most of the time the open source community lives under the grace of few very motivated engineers who actually care about OSS. I appreciate the efforts but it is a losing battle, unfortunately.
JJ’s governance is explicitly designed to be open source first and community driven. Its contributing guideline explicitly avoids conflicts of interest:
To avoid conflicts of interest, please don’t merge a PR that has only been approved by someone from the same organization.
— contributing.md in jj-vcs/jj
As far as I can tell, all the discussions and developments are happening in public on GitHub/Discord. The GitHub repo is the source of truth.
In Sapling, there is hg fsl that will only show you a focused view of your
current stack. As someone who often leaves commits sitting in my checkout
forever, I use this constantly.
[aliases]
fsl = ["log", "-r", "fork_point(::@ ~ ::trunk())- | fork_point(::@ ~ ::trunk()):: | trunk() | ancestors(::@ ~ ::trunk(), 2)"]
Demo:
Sometimes I would like to see what edit AI I did before I update my PR.
At Meta we had sl diff --since-last-submit. This is the JJ version:
diff-since-last-submit = ["util", "exec", "--", "bash", "-c", """
set -euo pipefail
REVSET="${1:-@}"
BOOKMARK="$(jj log --no-graph -r "$REVSET" -T 'bookmarks.first().name()')"
jj interdiff --from "$BOOKMARK"@origin --to "$BOOKMARK"
""", ""]
I know, it’s ugly, but this is how JJ documentation is recommending. Don’t forget the last empty string argument. :shrug:
JJ has Watchman support out of the box, you should enable it! It makes snapshotting really fast:
[fsmonitor]
backend = "watchman"
However, there is also a HUGE gotcha —
fsmonitor.watchman.register-snapshot-trigger option will create a Watchman
trigger that attemps to run jj debug snapshot whenever there is some change in
your repository. This practically offsets JJ snapshotting to be event driven.
This sounds like a great idea on paper, but the bad news is that jj debug snapshot is not concurrent-safe. If you have multiple jj debug snapshot run
at the same time, you will get a “working copy is stale” error:
Error: The working copy is stale (not updated since operation abcdef123456).
Hint: Run `jj workspace update-stale` to update it.
See https://jj-vcs.github.io/jj/latest/working-copy/#stale-working-copy for more information.
Not only the command will conflict with itself, I suspect it also conflicts with
my own jj log commands as it by default runs snapshotting, and this will
conflict with Watchman triggered ones as well.
Fortunately you can disable this by adding:
[ui]
default-command = ["log", "--ignore-working-copy"]
To resolve the Watchman triggered conflicts, I recommend disabling the default Watchman trigger JJ creates:
# You do NOT need to add this. It's false by default.
[fsmonitor.watchman]
register-snapshot-trigger = false
I asked Claude to write a jj debug snapshot with locks — jj-snapshot-locked:
#!/bin/bash
# Configuration
LOCKFILE="/tmp/jj_snapshot.lock"
TIMEOUT_DURATION="10m"
COMMAND_TO_RUN="jj debug snapshot" # Replace this with your actual command
# 1. Use flock to manage the lock
# -n: non-blocking (exit if another instance is running)
# -E 0: if it fails to get a lock, exit with 0 (or change to 1 if you want an error)
exec 9>"$LOCKFILE"
if ! flock -n 9; then
echo "Process is already running. Exiting."
exit 0
fi
echo "Lock acquired. Starting process with a $TIMEOUT_DURATION timeout..."
# 2. Use timeout to manage the duration
# --signal=KILL: sends a SIGKILL if the process doesn't stop after the initial SIGTERM
timeout --signal=KILL "$TIMEOUT_DURATION" $COMMAND_TO_RUN
# 3. Capture the exit status
STATUS=$?
if [ $STATUS -eq 124 ]; then
echo "Error: Process timed out and was killed after $TIMEOUT_DURATION."
elif [ $STATUS -eq 137 ]; then
echo "Error: Process was forcefully killed (SIGKILL)."
else
echo "Process finished successfully with exit code $STATUS."
fi
Then you can create the Watchman trigger youself:
watchman -j <<-EOT
[
"trigger",
"<path to git repo>",
{
"name": "jj-background-monitor-locked",
"expression": [
"not",
[
"anyof",
[
"name",
[
".git",
".jj"
],
"wholename"
],
[
"dirname",
".git"
],
[
"dirname",
".jj"
],
["suffix", "log"]
]
],
"command": [ "<path to jj-snapshot-locked>" ]
}
]
EOT
I have been using this setup for a month or so and I haven’t seen any more stale working copy issues.
You should also consider excluding frequently written directories in your
.watchmanconfig file. This will also significantly reduce the number of
snapshot being triggered:
{
"ignore_dirs": ["node_modules"]
}
(One more note, there are some limitations with the ignore_dirs option. Read
more about it in Watchman
documentation)
By default JJ moves by creating new empty commit on top of the destination. This config makes JJ moves like Sapling (where by default edits a commit):
[ui.movement]
edit = true
There are some aliases I have strong muscle memories for, and I am too stubbon to change:
[aliases]
hide = ["abandon"]
top = ["edit", "-r", "heads(@::)"]
bottom = ["edit", "-r", "fork_point(::@ ~ ::trunk())"]
At last, trunk should always be on the left:
[revsets]
log-graph-prioritize = "trunk()"
Despite some minor bumps, overall I have a great time using JJ. I am happy to learn new ideas in this area, and Git really feels like an old project now…
Next Up: bunnylol, jellyfish, worktree in JJ, …