jj / How jj abandon works

How jj abandon works

jj abandon removes commits from the visible history. Their changes disappear and their descendants are rebased onto their parents. Bookmarks pointing at them are deleted, unless you ask to move them to the parents instead.

On this page
  1. 1. Command-line surface
  2. 2. Configuration
  3. 3. End-to-end flow
  4. 4. Core architecture touchpoints
  5. 5. Transaction and operation
  6. 6. Algorithm and invariants
  7. Choosing the targets
  8. One walk over the descendants
  9. Bookmarks and the working copy
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: jj resolves the revisions (default @), keeps only the visible ones, and refuses if any is immutable. One transform_descendants_with_options walk records each target as abandoned onto its (possibly rewritten) parents. It rebases every other descendant, or with --restore-descendants, reparents it so its content stays the same. Bookmarks are then updated, which by default means deleting the abandoned commits' bookmarks. If @ was abandoned, a new empty @ is created on its parents. One operation, abandon commit <id>, records it all. The commits still exist in the store, and jj undo brings them back.

The command is cmd_abandon. Its help text compares it with jj restore --changes-in, which removes the same changes but keeps the change rather than giving you a new one (source). The same "abandoned commit" machinery runs whenever another command empties a commit: squash sources, rebase --skip-emptied, and the empty working-copy commit edit and new leave behind.

1. Command-line surface

FlagTypeDefaultMeaning
REVSETS (positional)revsets, repeatable@The commits to abandon. Positional and -r values are unioned (source).
-r REVSETSrevsets, repeatablenoneHidden alias for the positional argument (source).
--retain-bookmarksboolfalseMove the abandoned commits' bookmarks to their parents instead of deleting them (source).
--restore-descendantsboolfalseReparent descendants without changing their content, so the abandoned changes effectively move into the children (source).

AbandonArgs

2. Configuration

jj abandon reads no settings of its own. revset-aliases."immutable_heads()" decides what check_rewritable_expr refuses, so the root commit and anything immutable can't be abandoned. user.name/user.email become the committer of rebased descendants. For the "push deleted bookmarks" hint, the default remote that jj ignores when looking for tracked bookmarks is git on Git-backed repositories (has_tracked_remote_bookmarks).

3. End-to-end flow

jj abandon: call flow cmd_abandon loads the workspace through the shared lifecycle, which snapshots the working copy. It unions the revsets (default @), intersects them with the visible commits, checks those are rewritable, and reports any already-hidden ones it skips. With nothing left it prints "No revisions to abandon." Otherwise it starts a transaction and runs transform_descendants_with_options with delete_abandoned_bookmarks set unless --retain-bookmarks. For each visited commit, a target is abandoned onto its new parents; another descendant is rebased, or reparented with --restore-descendants. Bookmarks and working-copy pointers are updated. The command lists abandoned commits and deleted bookmarks, and tx.finish records "abandon commit". Afterwards it may hint about pushing deleted bookmarks. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) targets = revsets (or @) ∩ ::visible_heads() check_rewritable_expr(visible) "Skipping N … already hidden." none left? "No revisions to abandon." — no operation start_transaction; transform_descendants_with_options(targets, delete_abandoned_bookmarks = !--retain-bookmarks) visit targets and all descendants, parents first: • target → rewriter.abandon(): record abandoned onto its (rewritten) parents • other → rewriter.rebase().write(), or reparent().write() with --restore-descendants then update_rewritten_references: bookmarks deleted or moved, @ replaced report "Abandoned N commits:", "Deleted bookmarks: …", "Rebased N descendant commits …" tx.finish(ui, "abandon commit <id> …") write the operation, check out the new @ if it changed (CLI page) Git backend + tracked remote? hint: jj git push --deleted

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Target commitsabandonedRecorded in the transaction's parent_mapping as Rewrite::Abandoned(new parents) (source). They aren't deleted: they stay in the store, simply no longer reachable from the view's heads.transactions
Descendant commitsrewrittenRebased with a 3-way tree merge, which drops the abandoned changes and may conflict, or reparented with the same tree under --restore-descendants.rebase
View.head_idswriteAbandoned heads are removed. Their parents, or the rebased descendants, become heads as appropriate.view
View.local_bookmarkswriteA bookmark on an abandoned commit is set absent (deleted), or with --retain-bookmarks moved to the commit's new parents. Bookmarks on rebased commits follow them (update_local_bookmarks).view
View.remote_bookmarksreadOnly to decide whether to show the "push --deleted" hint. Remote bookmarks aren't changed: the deletion reaches the remote only when you push.view
View.wc_commit_idswriteA workspace whose @ was abandoned gets a new empty commit on the abandoned commit's new parents (update_wc_commits).view
OperationwriteOne operation, described below.operations
Working copy (TreeState)read, then maybe writeSnapshotted at the start. If @ was abandoned or rebased, its new tree is checked out, so files from abandoned ancestors disappear from disk.working-copy

5. Transaction and operation

  • Transaction: one, only if at least one visible commit is to be abandoned (source).
  • Operation description (source): abandon commit <id> or abandon commit <id> and <n> more. The ID is formatted with Display, which writes the full hex (core/src/object_id.rs). It is the first target in evaluation order, which puts children first.
  • Commits created or rewritten: none are created, except a replacement @ when @ is abandoned. Every descendant of a target is rewritten.
  • Working copy: snapshotted first, so abandoning @ abandons your latest edits too. The new @'s tree, the merge of its parents, is then checked out.
  • jj undo restores the previous view, which brings back the abandoned commits, their bookmarks and the old descendants. Nothing was deleted from the store.

6. Algorithm and invariants

Choosing the targets

The revsets are unioned, then intersected with ::visible_heads() (source). A revset can name hidden commits, for example by full commit ID. Those are counted and reported as skipped rather than abandoned again (source). The immutability check runs only on the visible part.

One walk over the descendants

Everything happens in one call to transform_descendants_with_options rooted at the targets (source). It visits the targets and all their descendants, parents first. Each visit gets a CommitRewriter whose new parents already reflect earlier visits. So when a target's parent was itself abandoned, the new parents are that parent's parents, and chains of abandoned commits collapse correctly.

  • Target: rewriter.abandon() records Abandoned(new parents) without writing anything. Later commits that had it as a parent get those parents instead.
  • Other descendant, default: rebase() merges new base + (tree − old base). The abandoned changes therefore vanish from every descendant, and a descendant that built on them may now conflict.
  • Other descendant, --restore-descendants: reparent() keeps the tree as it was. Its diff against the new parent now includes the abandoned changes, as if they'd been squashed into it.

Bookmarks and the working copy

At the end of the walk, update_rewritten_references runs with RewriteRefsOptions { delete_abandoned_bookmarks: !retain_bookmarks } (source). update_local_bookmarks sets a bookmark on an abandoned commit to absent. With --retain-bookmarks, it builds a new target from the new parents with the old commit interleaved as the "removed" side. When the abandoned commit was a merge, that means the bookmark becomes conflicted across both parents, shown as e?? in the upstream test below. The command then diffs the old and new bookmark tables to list what was deleted (source). update_wc_commits gives a workspace whose @ was abandoned a new empty commit on the new parents, with their merged tree.

Invariants: nothing is deleted from storage, so the operation is fully undoable. Every descendant survives, rebased or reparented, and none is abandoned implicitly. The root commit and immutable commits can't be abandoned. Abandoned changes don't vanish under --restore-descendants; they move into the children.

7. Errors and edge cases

MessageWhenSource
No revisions to abandon.Status message: no visible commit matched. No operation is recorded.abandon.rs
Skipping <n> revisions that are already hidden.Status message: some matches were hidden commits.abandon.rs
Commit … is immutable / The root commit … is immutableA visible target is immutable, for example jj abandon 'root()'.cli_util.rs
Abandoned <n> commits: followed by summaries; Deleted bookmarks: …Status output on success.abandon.rs
Rebased <n> descendant commits onto parents of abandoned commits. (or … (while preserving their content) …)Some descendants were rewritten.abandon.rs
Hint: Deleted bookmarks can be pushed by name or all at once with `jj git push --deleted`.Git backend, and a deleted bookmark has a tracked remote bookmark, ignoring the git pseudo-remote.abandon.rs

8. Worked example

From upstream's test_basics. b is on a, d is on c, and e (@) merges a and d. Each commit has a bookmark of its own name and adds a file of that name. Run jj abandon 'descendants(d)':

jj abandon 'descendants(d)': before and after Before: a and c on the root, b on a, d on c, and e at @ merging a and d. After abandoning d and e: both are gone and their bookmarks d and e are deleted. Because @ was abandoned, a new empty working-copy commit merges e's new parents, a and c (d's parent). a, b and c are unchanged. before after a c b d e @ a c b d e (empty) @ jj abandon 'descendants(d)'

Upstream's snapshot of the output:

Abandoned 2 commits:
  znkkpsqq 03e0d4bf e | e
  vruxwmqv 636920e4 d | d
Deleted bookmarks: d, e
Working copy  (@) now at: xtnwkqum 1c70f4d2 (empty) (no description set)
Parent commit (@-)      : rlvkpnrz 7d980be7 a | a
Parent commit (@-)      : royxmykx c12952d9 c | c
Added 0 files, modified 0 files, removed 2 files

e is listed first because it comes first in evaluation order, so the operation is abandon commit 03e0d4bf… and 1 more. d was abandoned onto c. When the walk reached e, its new parents were already a and c, so the replacement @ merges those two. The same test shows the bookmark variants. jj abandon --retain-bookmarks d moves bookmark d onto c. Abandoning the merge e with --retain-bookmarks leaves bookmark e conflicted between a and d, shown as a e?? and d e??.