Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo

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