Pushing changes with jj
How work gets from your working copy to a change on Dogfood.
This repository is a jj repo with a git backend. jj git export keeps a git view in
sync, which means git status and the git reflog see only part of the picture — the
authoritative history is jj's, and jj op log is the thing that can undo a mistake.
The model, in one paragraph
There is no staging area and no "uncommitted work". The working copy is a commit
(@), and jj snapshots it into that commit before running any command. You do not
create a commit to save work; the work is already in one. What you do instead is give
that commit a description, and then push a bookmark pointing at it. Pushing a bookmark
is what opens or updates a change — there is no web form and no PR button.
The normal loop
# 1. Start a new commit on top of the trunk.
jj new main
# 2. Edit files. jj snapshots them into @ automatically — nothing to add.
# 3. Describe what you did.
jj describe -m 'feat: cache shortest-unique prefixes per repo'
# 4. Push. This creates a bookmark named push-<change-id> and opens a change.
jj git push -c @
# 5. Start the next piece of work on top.
jj new
Step 4 prints the change URL. That change is now open for review.
Amending a change after review comments
Because @ is still the commit you pushed, you just keep editing it and push again:
# edit files …
jj git push -b push-<change-id>
jj reports this as [move sideways from <old> to <new>]. "Sideways" means a rewrite
rather than a fast-forward — expected, because you changed a commit that was already
published. No --force is required: jj compares the remote against the position it last
recorded, and pushes only when those agree. If someone else moved the bookmark
underneath you, that check fails instead of clobbering their work.
On Dogfood this lands as a new revision on the same change. The change id is stable across every amend, rebase and force-push, which is the whole point — reviews stay attached to the change, not to a commit hash.
If you have already moved on with jj new and @ is no longer the commit you want to
amend:
jj edit <change-id> # make that commit the working copy again
# edit files …
jj git push -b push-<change-id>
jj new # go back to working on top
Checking before you push
jj st # what changed in @
jj diff --stat # the diff, summarised
jj log -r 'stack(@)' # the stack this change sits in
jj git push -b <name> --dry-run # exactly what would move, without moving it
--dry-run is worth the extra command whenever you are about to rewrite something that
is already published.
Naming the bookmark yourself
-c/--change generates push-<change-id>, which is fine for most work. To push under a
name a human chose:
jj bookmark create my-feature -r @
jj git push -b my-feature
Landing on main
jj bookmark move main --to @
jj git push -b main
This bypasses review entirely, so it is for trivial or already-reviewed work only. The normal path is the bookmark push above.
The trap that will cost you a day
jj new main -m 'feat: my work' # ← DO NOT use this to save work in progress
This does not put your current work in the new commit. jj snapshots the working copy
into the current commit first, then creates an empty commit whose parent is
main. You end up pushing an empty change while all your work sits in a now-orphaned
commit labelled (no description set) — and because the working copy was reset to
something matching main, git status reports a clean tree and the work looks deleted.
Use jj describe -m '…' to name work you already have. Use bare jj new (no revision)
only when you genuinely want to start something new.
Recovering from it
Nothing is lost — jj keeps every snapshot.
# 1. Find the stranded commit. It is a sibling with no description.
jj log -r 'all()'
# 2. Confirm it is the right one before touching anything.
jj show --stat <change-id>
# 3. Restore its contents into the current commit.
jj restore --from <change-id>
jj op log shows the tell: a snapshot working copy operation immediately followed by
new empty commit, both with the same args: line. To rewind the whole repository to
just before a bad command:
jj op log
jj op restore <operation-id>
Prefer jj restore --from when you only want the files back and do not want to rewind
bookmarks that were pushed in the meantime.
Deploying is a separate step
Pushing updates the change. It does not deploy. ./run.sh deploy builds from the
working copy on disk, not from what you pushed — so if the tree is in the stranded
state above, a deploy will quietly ship the previous build. After deploying, confirm
which build is actually live rather than assuming:
curl -s -o /dev/null -w '%{http_code}\n' https://dogfood.sh/assets/terminal.js
Any asset that exists only in the current build works as the probe.
See also
change-id-format.md— why change ids look the way they dorevset-semantics.md— the revset language used by-r