Skip to content

Instantly share code, notes, and snippets.

@paulsmith
Created February 6, 2026 21:01
Show Gist options
  • Select an option

  • Save paulsmith/6f2ff0529bedc6d1f60eac09d5fd0037 to your computer and use it in GitHub Desktop.

Select an option

Save paulsmith/6f2ff0529bedc6d1f60eac09d5fd0037 to your computer and use it in GitHub Desktop.
jj-unsquash: recover pre-squash commit history from jj's operation log

jj-unsquash example

Setup

Start with a jj repo. Create a workspace, make some commits, then squash them down:

$ jj workspace add --name bar ../bar
$ cd ../bar
$ echo 1 > 1.txt && jj commit -m "bar: 1.txt"
$ echo 2 > 2.txt && jj commit -m "bar: 2.txt"
$ echo 3 > 3.txt && jj commit -m "bar: 3.txt"
$ jj squash -f '@--::@-' -t @--- -m "bar: squashed"
$ cd ../default
$ jj workspace forget bar && rm -rf ../bar

Now the log shows a single clean commit:

$ jj log
@  zlltmlwq [email protected] 2026-02-06 14:44:05 8a1eb736
│  (no description set)
│ ○  ywmkwnlr [email protected] 2026-02-06 11:46:54 ed62a268
├─╯  bar: squashed
○  npxtutrx [email protected] 2026-02-06 11:14:07 5c1486e0
│  initial
◆  zzzzzzzz root() 00000000

Recovering the pre-squash history

Pass the squashed revision to jj-unsquash:

$ jj-unsquash ywmkwnlr
○  otxxpuxp [email protected] 2026-02-06 11:46:54 38184bf6
│  bar: 3.txt
○  vlrtvurz [email protected] 2026-02-06 11:46:54 c95b8e04
│  bar: 2.txt
○  ywmkwnlr [email protected] 2026-02-06 11:46:54 6123fa16
│  bar: 1.txt
~

The three original commits are recovered from the operation log.

Viewing a specific commit's diff

Use --diff with a change ID from the output above:

$ jj-unsquash ywmkwnlr --diff vlrtvurz
Added regular file 2.txt:
        1: 2

Restoring a workspace

Use --restore to create a browsable workspace with the pre-squash files:

$ jj-unsquash ywmkwnlr --restore
workspace created at ../unsquash-ywmkwnlr
clean up with: jj workspace forget unsquash-ywmkwnlr && rm -rf ../unsquash-ywmkwnlr
#!/bin/bash
# ABOUTME: Recovers pre-squash commit history from jj's operation log.
# ABOUTME: Supports viewing the chain, diffing individual commits, and restoring via workspace.
set -euo pipefail
jj-unsquash() {
local usage="usage: jj-unsquash <revision> [--diff <change-id>] [--restore]"
local revision=""
local mode="log"
local diff_change_id=""
# --- Argument parsing ---
while [[ $# -gt 0 ]]; do
case "$1" in
--diff)
mode="diff"
if [[ -z "${2:-}" ]]; then
echo "error: --diff requires a change-id argument" >&2
echo "$usage" >&2
return 1
fi
diff_change_id="$2"
shift 2
;;
--restore)
mode="restore"
shift
;;
-*)
echo "error: unknown option: $1" >&2
echo "$usage" >&2
return 1
;;
*)
if [[ -z "$revision" ]]; then
revision="$1"
else
echo "error: unexpected argument: $1" >&2
echo "$usage" >&2
return 1
fi
shift
;;
esac
done
if [[ -z "$revision" ]]; then
echo "$usage" >&2
return 1
fi
# --- Step 1: Resolve target revision to change ID ---
local change_id
change_id=$(jj log -r "$revision" -T 'change_id.short()' --no-graph)
# --- Step 2: Use obslog to find the squash operation ---
# The first obslog entry with multiple predecessors is the squash.
# The template emits: <parent-op-id> <predecessor-count> <predecessor-change-ids>
local obslog_line parent_op pred_count preds
# Limit to first entry only — use --limit 1 to avoid SIGPIPE from head
obslog_line=$(jj obslog -r "$revision" --no-graph --limit 1 -T \
'self.operation().parents().map(|p| p.id().short(12)).join(",") ++ " "
++ self.predecessors().len() ++ " "
++ self.predecessors().map(|c| c.change_id().short()).join(",") ++ "\n"')
read -r parent_op pred_count preds <<< "$obslog_line"
if [[ "$pred_count" -lt 2 ]]; then
echo "error: revision '$revision' (change $change_id) does not appear to be a squash result" >&2
echo " (obslog shows $pred_count predecessors, expected 2+)" >&2
return 1
fi
# --- Step 3: Dispatch based on mode ---
# All descendants of the target change at the pre-squash op, excluding the working-copy tip
local revset="$change_id:: ~ heads($change_id::)"
case "$mode" in
log)
jj log --at-op "$parent_op" -r "$revset"
;;
diff)
jj diff --at-op "$parent_op" -r "$diff_change_id"
;;
restore)
local tip short_cid workspace_name
tip=$(jj log --at-op "$parent_op" \
-r "heads($change_id:: ~ heads($change_id::))" \
-T 'change_id.short()' --no-graph)
short_cid=$(echo "$change_id" | cut -c1-8)
workspace_name="unsquash-${short_cid}"
# Must use --at-op because pre-squash commits are abandoned at the current op
jj --at-op "$parent_op" workspace add --name "$workspace_name" -r "$tip" "../${workspace_name}"
echo "workspace created at ../${workspace_name}"
echo "clean up with: jj workspace forget ${workspace_name} && rm -rf ../${workspace_name}"
;;
esac
}
# Allow running directly as a script
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
jj-unsquash "$@"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment