Skip to content

Instantly share code, notes, and snippets.

@wrs
Created March 13, 2026 22:19
Show Gist options
  • Select an option

  • Save wrs/53533ba74bde3690943468bfa37e4155 to your computer and use it in GitHub Desktop.

Select an option

Save wrs/53533ba74bde3690943468bfa37e4155 to your computer and use it in GitHub Desktop.
jj: rebase branch containing conflicts in renamed files

jj-rebase-rename

Automates rebasing a jj branch when files have been renamed on the target. Based on the technique described in Rebasing Renamed Files with jj.

The problem

When you rebase a branch that modifies files at path A onto a commit where those files were moved to path B, jj produces whole-file conflicts instead of meaningful diffs. This makes the conflicts much harder to resolve than they need to be.

How it works

The script creates a temporary duplicate of your branch with file renames applied to each commit's changed files, rebases that duplicate onto the target (avoiding the rename-related conflicts), then copies the resolved content back into your original commits. Only files each commit actually changes are touched — inherited files are left alone to avoid spurious conflicts.

The rename-only commit is abandoned after rebase so it doesn't appear in the final history.

Requirements

  • Python 3.12+
  • uv (for the script shebang)
  • jj

Usage

Run from within a jj repository, with @ on the head of the branch you want to rebase.

Rebase with explicit renames

./jj-rebase-rename.py \
  --rename old/path/foo.rb:new/path/foo.rb \
  --rename old/path/bar.rb:new/path/bar.rb \
  --onto trunk

--rename OLD:NEW is repeatable. --onto is required and takes any jj revset.

Auto-detect renames

./jj-rebase-rename.py --detect --onto trunk

Compares your branch's files against the --onto target using jj's rename detection. Prints the suggested --rename flags to use. Only detects renames for files your branch actually touches.

Undo

The script prints a jj op restore command at the start and end. If the result doesn't look right:

jj op restore <operation-id>

Limitations

  • Only resolves conflicts caused by file renames. Genuine content conflicts (e.g. both your branch and trunk edited config/routes.rb) will remain and need manual resolution.
  • --detect relies on jj recognizing the rename (reported as R in jj diff --summary). If jj sees it as a delete + add rather than a rename, you'll need to pass --rename explicitly.
  • The script operates on ::@ & mutable() — all mutable ancestors of the current working copy commit. Make sure @ is at the tip of the branch you want to rebase.

Tests

uv run test_jj_rebase_rename.py

Creates temporary jj repos and exercises the script against two scenarios:

  1. Basic rename — files renamed on trunk, branch modifies them
  2. Inherited files — later commits in the branch don't touch the renamed files but inherit them in the tree (must not produce spurious conflicts)
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# ///
"""Automate jj rebase when files have been renamed on trunk.
See: https://blog.eliaswatson.dev/posts/jj_rebase_rename/
This was 95% vibecoded by Claude Code. USE AT YOUR OWN RISK.
jj op restore is your friend.
"""
import argparse
import os
import subprocess
import sys
def _run_jj(cmd: list[str], check: bool) -> subprocess.CompletedProcess:
"""Run a jj command with retries on lock contention."""
import time
for attempt in range(5):
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode == 0 or "Could not acquire lock" not in result.stderr:
break
if attempt < 4:
time.sleep(0.5)
if result.returncode != 0 and check:
print(f"jj command failed: {' '.join(cmd[1:])}", file=sys.stderr)
print(f"stderr: {result.stderr.strip()}", file=sys.stderr)
result.check_returncode()
return result
def jj(*args: str, capture: bool = True, check: bool = True) -> str:
"""Run a jj command and return stdout."""
result = _run_jj(["jj", "--ignore-working-copy", *args], check)
return result.stdout.strip() if capture else ""
def jj_wc(*args: str, capture: bool = True, check: bool = True) -> str:
"""Run a jj command that needs working copy updates (new, edit, etc.)."""
result = _run_jj(["jj", *args], check)
return result.stdout.strip() if capture else ""
def get_change_id() -> str:
"""Get the change ID of the current working copy commit."""
return jj("log", "-r", "@", "--no-graph", "-T", "change_id")
def get_description(change_id: str) -> str:
"""Get the description of a commit."""
return jj("log", "-r", change_id, "--no-graph", "-T", "description")
def get_repo_root() -> str:
"""Get the root of the jj repository."""
return jj("root")
def get_branch_commits() -> list[str]:
"""Get mutable commits from root to head, oldest first."""
output = jj("log", "-r", "::@ & mutable()", "--no-graph", "-T", r'change_id ++ "\n"', "--reversed")
return [line for line in output.splitlines() if line.strip()]
def apply_renames(repo_root: str, renames: list[tuple[str, str]]) -> None:
"""Apply file renames in the working copy."""
for old, new in renames:
old_path = os.path.join(repo_root, old)
new_path = os.path.join(repo_root, new)
if os.path.exists(old_path):
os.makedirs(os.path.dirname(new_path) if os.path.dirname(new_path) else repo_root, exist_ok=True)
os.rename(old_path, new_path)
def detect_renames(onto: str) -> list[tuple[str, str]]:
"""Detect file renames between branch base and onto target.
Parses jj's rename detection (R lines) from diff --summary, filtered
to only files the branch actually touches.
"""
import re
# Find files the branch touches
branch_output = jj("diff", "--summary", "-r", "::@ & mutable()")
branch_files: set[str] = set()
for line in branch_output.splitlines():
line = line.strip()
if line and line[0] in "AMDR" and line[1] == " ":
path = line[2:].strip()
# R lines use format: {old => new}/rest or path/{old => new}/rest
# Extract the actual old path for matching
if line[0] == "R":
old_path = _expand_rename_path(path, use_old=True)
if old_path:
branch_files.add(old_path)
else:
branch_files.add(path)
# Find renames between base and onto
base = jj("log", "-r", "roots(::@ & mutable())-", "--no-graph", "-T", "change_id")
trunk_output = jj("diff", "--summary", "--from", base, "--to", onto)
renames = []
for line in trunk_output.splitlines():
line = line.strip()
if not line.startswith("R "):
continue
path = line[2:].strip()
old_path = _expand_rename_path(path, use_old=True)
new_path = _expand_rename_path(path, use_old=False)
if old_path and new_path and old_path in branch_files:
renames.append((old_path, new_path))
return renames
def _expand_rename_path(path: str, use_old: bool) -> str | None:
"""Expand a jj rename path like 'a/{b => c}/d' to 'a/b/d' or 'a/c/d'.
Handles formats:
{old => new}/rest
prefix/{old => new}/rest
prefix/{old => new}
"""
import re
m = re.search(r'\{(.*?) => (.*?)\}', path)
if not m:
return None
replacement = m.group(1) if use_old else m.group(2)
expanded = path[:m.start()] + replacement + path[m.end():]
# Clean up double slashes from empty sides like {flow => }/file
while "//" in expanded:
expanded = expanded.replace("//", "/")
return expanded
def main() -> int:
parser = argparse.ArgumentParser(description="Rebase a jj branch after files were renamed on trunk")
parser.add_argument("--rename", action="append", metavar="OLD:NEW",
help="File rename pair (repeatable)")
parser.add_argument("--detect", action="store_true",
help="Detect renames and print suggested --rename args")
parser.add_argument("--onto", required=True,
help="Rebase destination revset")
args = parser.parse_args()
# Verify we're in a jj repo
try:
repo_root = get_repo_root()
except subprocess.CalledProcessError:
print("Error: not in a jj repository", file=sys.stderr)
return 1
if args.detect:
renames = detect_renames(args.onto)
if not renames:
print("No renames detected.", file=sys.stderr)
return 0
rename_args = " ".join(f"--rename {old}:{new}" for old, new in renames)
print(f"Detected renames. Suggested command:", file=sys.stderr)
print(f" jj-rebase-rename.py {rename_args} --onto {args.onto}")
return 0
if not args.rename:
print("Error: --rename is required (or use --detect)", file=sys.stderr)
return 1
renames = []
for r in args.rename:
if ":" not in r:
print(f"Error: invalid rename format '{r}', expected OLD:NEW", file=sys.stderr)
return 1
old, new = r.split(":", 1)
renames.append((old, new))
# Save operation for potential undo
op_id = jj("operation", "log", "--no-graph", "-T", "id", "--limit", "1")
print(f"Pre-operation ID: {op_id[:12]}... (use 'jj op restore {op_id[:12]}' to undo)")
# Step 1: Get branch commits
branch_commits = get_branch_commits()
if not branch_commits:
print("Error: no mutable commits found", file=sys.stderr)
return 1
print(f"Found {len(branch_commits)} commit(s) in the current branch")
# Step 2: Find the base
base = jj("log", "-r", "roots(::@ & mutable())-", "--no-graph", "-T", "change_id")
print(f"Base commit: {base[:12]}")
# Save descriptions for each original commit
descriptions = {}
for cid in branch_commits:
descriptions[cid] = get_description(cid)
# Step 3: Build duplicate branch with renames
print("Building duplicate branch with renames...")
# Create rename-only commit from base
jj_wc("new", base)
apply_renames(repo_root, renames)
jj_wc("describe", "-m", "automated: apply renames")
rename_change = get_change_id()
print(f" Rename commit: {rename_change[:12]}")
# Build rename map for quick lookup
rename_map = {old: new for old, new in renames}
# Duplicate each original commit with renames applied
commit_map: dict[str, str] = {} # original -> duplicate
for cid in branch_commits:
jj_wc("new")
# Get the files this commit changes (its diff, not its full tree)
diff_output = jj("diff", "--summary", "-r", cid)
changed_files = []
for line in diff_output.splitlines():
line = line.strip()
if line and len(line) > 2 and line[1] == " ":
changed_files.append(line[2:].strip())
if changed_files:
# Restore only the changed files from the original commit
jj_wc("restore", "--from", cid, *changed_files)
# For any restored files that need renaming, do the rename
# in the working copy
for f in changed_files:
if f in rename_map:
old_path = os.path.join(repo_root, f)
new_path = os.path.join(repo_root, rename_map[f])
if os.path.exists(old_path):
os.makedirs(os.path.dirname(new_path) or repo_root, exist_ok=True)
os.rename(old_path, new_path)
desc = descriptions[cid]
if desc:
jj_wc("describe", "-m", desc)
new_cid = get_change_id()
commit_map[cid] = new_cid
print(f" {cid[:12]} -> {new_cid[:12]}")
# Step 4: Rebase duplicate branch onto target
print(f"Rebasing duplicate branch onto {args.onto}...")
jj("rebase", "-s", rename_change, "-d", args.onto)
# Step 5: Abandon the rename-only commit
print("Abandoning rename-only commit...")
jj("abandon", rename_change)
# Step 6: Rebase original branch onto target
print("Rebasing original branch onto target...")
first_original = branch_commits[0]
jj("rebase", "-s", first_original, "-d", args.onto)
# Step 7: Copy resolved content back
print("Copying resolved content to original commits...")
for orig_cid, dup_cid in commit_map.items():
jj("restore", "--from", dup_cid, "--into", orig_cid)
print(f" {orig_cid[:12]} <- {dup_cid[:12]}")
# Step 8: Clean up
print("Cleaning up duplicate commits...")
dup_ids = list(commit_map.values())
jj("abandon", *dup_ids)
# Update stale working copy and edit back to the original head
jj_wc("workspace", "update-stale", check=False)
original_head = branch_commits[-1]
jj_wc("edit", original_head)
print("Done. If the result doesn't look right, undo with:")
print(f" jj op restore {op_id[:12]}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# ///
"""Test for jj-rebase-rename.py.
Creates temporary jj repos with rename scenarios and verifies the script works.
"""
import os
import subprocess
import sys
import tempfile
SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "jj-rebase-rename.py")
def jj(*args: str, cwd: str) -> str:
result = subprocess.run(["jj", *args], capture_output=True, text=True, check=True, cwd=cwd)
return result.stdout.strip()
def jj_no_wc(*args: str, cwd: str) -> str:
result = subprocess.run(["jj", "--ignore-working-copy", *args], capture_output=True, text=True, check=True, cwd=cwd)
return result.stdout.strip()
def write_file(repo: str, name: str, content: str) -> None:
path = os.path.join(repo, name)
os.makedirs(os.path.dirname(path) if os.path.dirname(path) else repo, exist_ok=True)
with open(path, "w") as f:
f.write(content)
def read_file(repo: str, name: str) -> str:
path = os.path.join(repo, name)
with open(path) as f:
return f.read()
def run_script(repo: str, *args: str) -> subprocess.CompletedProcess:
result = subprocess.run(
[sys.executable, SCRIPT, *args],
cwd=repo,
capture_output=True,
text=True,
)
print("STDOUT:", result.stdout)
if result.stderr:
print("STDERR:", result.stderr)
return result
def check_no_conflicts(repo: str) -> list[str]:
log_output = jj_no_wc("log", "-r", "all()", "--no-graph", "-T",
r'if(conflict, change_id ++ " CONFLICT\n", "")', cwd=repo)
if "CONFLICT" in log_output:
return [f"Conflicts found: {log_output}"]
return []
def make_immutable(repo: str, change_id: str) -> None:
config_path = os.path.join(repo, ".jj", "repo", "config.toml")
with open(config_path, "a") as f:
f.write(f'\n[revset-aliases]\n"immutable_heads()" = "{change_id} | trunk()"\n')
# ---------------------------------------------------------------------------
# Test: basic rename scenario
# ---------------------------------------------------------------------------
def test_basic_rename() -> bool:
"""Files A,B,C renamed to D,E,F on trunk. Branch modifies A and B."""
with tempfile.TemporaryDirectory() as tmpdir:
repo = os.path.join(tmpdir, "testrepo")
os.makedirs(repo)
jj("git", "init", cwd=repo)
# Base commit with initial files
write_file(repo, "A.txt", "initial A content\n")
write_file(repo, "B.txt", "initial B content\n")
write_file(repo, "C.txt", "initial C content\n")
jj("describe", "-m", "initial commit", cwd=repo)
base = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
make_immutable(repo, base)
# Feature branch: two commits modifying A and B
jj("new", "-m", "feature: modify A and B", cwd=repo)
write_file(repo, "A.txt", "initial A content\nfeature line in A\n")
write_file(repo, "B.txt", "initial B content\nfeature line in B\n")
feature1 = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
jj("new", "-m", "feature: more changes to A", cwd=repo)
write_file(repo, "A.txt", "initial A content\nfeature line in A\nsecond feature line\n")
feature2 = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
# Trunk: rename A->D, B->E, C->F
jj("new", base, "-m", "rename files", cwd=repo)
os.rename(os.path.join(repo, "A.txt"), os.path.join(repo, "D.txt"))
os.rename(os.path.join(repo, "B.txt"), os.path.join(repo, "E.txt"))
os.rename(os.path.join(repo, "C.txt"), os.path.join(repo, "F.txt"))
trunk = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
jj("bookmark", "set", "trunk", "-r", trunk, cwd=repo)
# Go back to feature head
jj("edit", feature2, cwd=repo)
print("\nBefore:")
print(jj_no_wc("log", "-r", "all()", cwd=repo))
# Run script
result = run_script(repo, "--rename", "A.txt:D.txt", "--rename", "B.txt:E.txt",
"--rename", "C.txt:F.txt", "--onto", "trunk")
if result.returncode != 0:
print("FAILED: script exited with non-zero")
return False
print("\nAfter:")
print(jj_no_wc("log", "-r", "all()", cwd=repo))
errors = check_no_conflicts(repo)
jj("workspace", "update-stale", cwd=repo)
jj("edit", feature2, cwd=repo)
content = read_file(repo, "D.txt")
if "feature line in A" not in content or "second feature line" not in content:
errors.append(f"D.txt missing feature content: {content!r}")
content = read_file(repo, "E.txt")
if "feature line in B" not in content:
errors.append(f"E.txt missing feature content: {content!r}")
desc1 = jj_no_wc("log", "-r", feature1, "--no-graph", "-T", "description", cwd=repo)
if "feature: modify A and B" not in desc1:
errors.append(f"Feature1 description lost: {desc1!r}")
desc2 = jj_no_wc("log", "-r", feature2, "--no-graph", "-T", "description", cwd=repo)
if "feature: more changes to A" not in desc2:
errors.append(f"Feature2 description lost: {desc2!r}")
if errors:
print("\nFAILED:")
for e in errors:
print(f" - {e}")
return False
return True
# ---------------------------------------------------------------------------
# Test: later commit doesn't touch renamed files (inherited tree)
# ---------------------------------------------------------------------------
def test_inherited_files_not_conflicting() -> bool:
"""A later commit modifies only one renamed file. The other renamed file
is inherited in the tree but not changed by the commit. This must not
produce spurious conflicts for the inherited file."""
with tempfile.TemporaryDirectory() as tmpdir:
repo = os.path.join(tmpdir, "testrepo")
os.makedirs(repo)
jj("git", "init", cwd=repo)
# Base: two files under sub/
write_file(repo, "sub/foo.rb", "foo original\n")
write_file(repo, "sub/bar.rb", "bar original\n")
write_file(repo, "other.txt", "other\n")
jj("describe", "-m", "initial commit", cwd=repo)
base = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
make_immutable(repo, base)
# Feature: commit 1 modifies both files
jj("new", "-m", "modify foo and bar", cwd=repo)
write_file(repo, "sub/foo.rb", "foo original\nfoo feature\n")
write_file(repo, "sub/bar.rb", "bar original\nbar feature\n")
f1 = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
# Feature: commit 2 only modifies foo (bar is inherited but untouched)
jj("new", "-m", "modify only foo", cwd=repo)
write_file(repo, "sub/foo.rb", "foo original\nfoo feature\nfoo more\n")
f2 = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
# Feature: commit 3 only modifies other.txt (neither renamed file touched)
jj("new", "-m", "modify other.txt only", cwd=repo)
write_file(repo, "other.txt", "other modified\n")
f3 = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
# Trunk: rename sub/foo.rb -> foo.rb, sub/bar.rb -> bar.rb
jj("new", base, "-m", "move files out of sub/", cwd=repo)
os.rename(os.path.join(repo, "sub", "foo.rb"), os.path.join(repo, "foo.rb"))
os.rename(os.path.join(repo, "sub", "bar.rb"), os.path.join(repo, "bar.rb"))
os.rmdir(os.path.join(repo, "sub"))
trunk = jj_no_wc("log", "-r", "@", "--no-graph", "-T", "change_id", cwd=repo)
jj("bookmark", "set", "trunk", "-r", trunk, cwd=repo)
jj("edit", f3, cwd=repo)
print("\nBefore:")
print(jj_no_wc("log", "-r", "all()", cwd=repo))
result = run_script(repo, "--rename", "sub/foo.rb:foo.rb",
"--rename", "sub/bar.rb:bar.rb", "--onto", "trunk")
if result.returncode != 0:
print("FAILED: script exited with non-zero")
return False
print("\nAfter:")
print(jj_no_wc("log", "-r", "all()", cwd=repo))
errors = check_no_conflicts(repo)
jj("workspace", "update-stale", cwd=repo)
jj("edit", f3, cwd=repo)
# foo.rb should have all the feature content
content = read_file(repo, "foo.rb")
if "foo feature" not in content or "foo more" not in content:
errors.append(f"foo.rb missing feature content: {content!r}")
# bar.rb should have feature content from f1
content = read_file(repo, "bar.rb")
if "bar feature" not in content:
errors.append(f"bar.rb missing feature content: {content!r}")
# other.txt should be modified
content = read_file(repo, "other.txt")
if "other modified" not in content:
errors.append(f"other.txt missing content: {content!r}")
# Descriptions preserved
for cid, expected in [(f1, "modify foo and bar"), (f2, "modify only foo"),
(f3, "modify other.txt only")]:
desc = jj_no_wc("log", "-r", cid, "--no-graph", "-T", "description", cwd=repo)
if expected not in desc:
errors.append(f"Description lost for {cid[:12]}: {desc!r}")
if errors:
print("\nFAILED:")
for e in errors:
print(f" - {e}")
return False
return True
# ---------------------------------------------------------------------------
def main() -> int:
tests = [
("basic rename", test_basic_rename),
("inherited files not conflicting", test_inherited_files_not_conflicting),
]
all_passed = True
for name, test_fn in tests:
print(f"\n{'='*60}")
print(f"TEST: {name}")
print(f"{'='*60}")
passed = test_fn()
print(f"\n{'PASSED' if passed else 'FAILED'}: {name}")
if not passed:
all_passed = False
print(f"\n{'='*60}")
print(f"{'ALL TESTS PASSED' if all_passed else 'SOME TESTS FAILED'}")
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment