Skip to content

Instantly share code, notes, and snippets.

@Konfekt
Created February 28, 2026 12:13
Show Gist options
  • Select an option

  • Save Konfekt/a54280af757ce2ec8b09416889c161f0 to your computer and use it in GitHub Desktop.

Select an option

Save Konfekt/a54280af757ce2ec8b09416889c161f0 to your computer and use it in GitHub Desktop.
autorebase all feature branches onto main
#!/usr/bin/env python3
"""Autorebase feature branches onto main/master without switching your current branch."""
# Uses https://github.com/Timmmm/autorebase as a reference
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Optional
class GitError(RuntimeError):
pass
@dataclass
class BranchInfo:
name: str
upstream: Optional[str]
worktree_path: Optional[Path]
def run_git(
args: List[str],
cwd: Path,
*,
check: bool = True,
text: bool = True,
) -> subprocess.CompletedProcess:
proc = subprocess.run(
["git", *args],
cwd=str(cwd),
capture_output=True,
text=text,
)
if check and proc.returncode != 0:
raise GitError(
f"git {' '.join(args)} failed (exit={proc.returncode})\n"
f"stdout:\n{proc.stdout}\n"
f"stderr:\n{proc.stderr}"
)
return proc
def git_output(args: List[str], cwd: Path) -> str:
return run_git(args, cwd).stdout.strip()
def get_repo_paths(cwd: Path) -> tuple[Path, Path]:
root = Path(
git_output(["rev-parse", "--path-format=absolute", "--show-toplevel"], cwd)
)
common = Path(
git_output(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd)
)
return root, common
def local_branch_exists(cwd: Path, branch: str) -> bool:
proc = run_git(
["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
cwd,
check=False,
)
return proc.returncode == 0
def choose_onto_branch(cwd: Path, explicit: Optional[str]) -> str:
if explicit:
if not local_branch_exists(cwd, explicit):
raise GitError(f"Target branch '{explicit}' does not exist in refs/heads.")
return explicit
if local_branch_exists(cwd, "main"):
return "main"
if local_branch_exists(cwd, "master"):
return "master"
raise GitError("Could not find local 'main' or 'master'. Pass --onto explicitly.")
def get_branches(cwd: Path) -> list[BranchInfo]:
out = run_git(
[
"for-each-ref",
"--format=%(refname:short)%00%(upstream:short)%00%(worktreepath)",
"refs/heads",
],
cwd,
).stdout
branches: list[BranchInfo] = []
for raw_line in out.splitlines():
if not raw_line:
continue
parts = raw_line.split("\0")
if len(parts) != 3:
raise GitError(f"Unexpected for-each-ref format: {raw_line!r}")
name = parts[0]
upstream = parts[1] or None
worktree = Path(parts[2]) if parts[2] else None
branches.append(BranchInfo(name=name, upstream=upstream, worktree_path=worktree))
return branches
def is_clean_worktree(path: Path) -> bool:
wt = run_git(["diff-index", "--quiet", "HEAD"], path, check=False)
if wt.returncode != 0:
return False
idx = run_git(["diff-index", "--quiet", "--cached", "HEAD"], path, check=False)
return idx.returncode == 0
def ensure_scratch_worktree(repo_root: Path, git_common_dir: Path) -> Path:
scratch = git_common_dir / "autorebase_py" / "worktree"
if scratch.is_dir():
return scratch
scratch.parent.mkdir(parents=True, exist_ok=True)
run_git(["worktree", "add", "--detach", str(scratch)], repo_root)
return scratch
def pull_target_branch(onto_branch: str, onto_info: BranchInfo, scratch: Path) -> None:
if onto_info.upstream is None:
print(f"- Not pulling '{onto_branch}' (no upstream)")
return
if onto_info.worktree_path and is_clean_worktree(onto_info.worktree_path):
print(f"- Pulling '{onto_branch}' in {onto_info.worktree_path}")
run_git(["pull", "--ff-only"], onto_info.worktree_path)
return
if onto_info.worktree_path and not is_clean_worktree(onto_info.worktree_path):
print(f"- Not pulling '{onto_branch}' (checked out worktree is dirty)")
return
print(f"- Pulling '{onto_branch}' in scratch worktree")
run_git(["switch", onto_branch], scratch)
try:
run_git(["pull", "--ff-only"], scratch)
finally:
run_git(["switch", "--detach"], scratch, check=False)
def merge_base(cwd: Path, a: str, b: str) -> str:
return git_output(["merge-base", a, b], cwd)
def commit_list_oldest_first(cwd: Path, from_commit: str, to_ref: str) -> list[str]:
out = git_output(["rev-list", "--reverse", f"{from_commit}..{to_ref}"], cwd)
if not out:
return []
return out.splitlines()
def attempt_rebase(cwd: Path, onto: str) -> bool:
proc = run_git(["rebase", onto], cwd, check=False)
if proc.returncode == 0:
return True
run_git(["rebase", "--abort"], cwd, check=False)
return False
def set_committer_date_to_now_if_unset() -> None:
if "GIT_COMMITTER_DATE" in os.environ:
return
os.environ["GIT_COMMITTER_DATE"] = f"@{int(time.time())} +0000"
def eligible_branches(
all_branches: Iterable[BranchInfo],
*,
onto_branch: str,
include_upstream: bool,
) -> list[BranchInfo]:
selected: list[BranchInfo] = []
for b in all_branches:
if b.name == onto_branch:
continue
if not include_upstream and b.upstream is not None:
print(f"- Skip {b.name}: has upstream ({b.upstream})")
continue
if b.worktree_path and not is_clean_worktree(b.worktree_path):
print(f"- Skip {b.name}: checked-out worktree is dirty ({b.worktree_path})")
continue
selected.append(b)
return selected
def rebase_branch(branch: BranchInfo, onto_branch: str, scratch: Path) -> None:
worktree = branch.worktree_path or scratch
using_scratch = branch.worktree_path is None
if using_scratch:
run_git(["switch", branch.name], worktree)
try:
base = merge_base(worktree, branch.name, onto_branch)
targets = commit_list_oldest_first(worktree, base, onto_branch)
if not targets:
print(f"- {branch.name}: up to date")
return
print(f"- {branch.name}: {len(targets)} target commits to apply")
rebased_count = 0
for target in targets:
ok = attempt_rebase(worktree, target)
if not ok:
print(
f" conflict at {target[:12]}; stopped after {rebased_count} successful step(s)"
)
return
rebased_count += 1
print(f" rebased to {onto_branch}")
finally:
if using_scratch:
run_git(["switch", "--detach"], worktree, check=False)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Automatically rebase feature branches onto main/master. "
"If conflicts occur, rebase only up to the last non-conflicting target commit."
)
)
parser.add_argument(
"--onto",
help="Target branch to rebase onto. Default: main, else master.",
)
parser.add_argument(
"--include-upstream",
action="store_true",
help="Include branches that have an upstream (default is to skip them).",
)
parser.add_argument(
"--no-pull",
action="store_true",
help="Do not run 'git pull --ff-only' on the target branch before rebasing.",
)
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
cwd = Path.cwd()
try:
repo_root, git_common = get_repo_paths(cwd)
onto = choose_onto_branch(repo_root, args.onto)
branches = get_branches(repo_root)
onto_info = next((b for b in branches if b.name == onto), None)
if onto_info is None:
raise GitError(f"Target branch '{onto}' not found.")
set_committer_date_to_now_if_unset()
scratch = ensure_scratch_worktree(repo_root, git_common)
if not args.no_pull:
pull_target_branch(onto, onto_info, scratch)
targets = eligible_branches(
branches,
onto_branch=onto,
include_upstream=args.include_upstream,
)
if not targets:
print("No eligible branches to rebase.")
return 0
print(f"Rebasing {len(targets)} branch(es) onto '{onto}'")
for b in targets:
rebase_branch(b, onto, scratch)
return 0
except GitError as e:
print(str(e), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment