Created
February 28, 2026 12:13
-
-
Save Konfekt/a54280af757ce2ec8b09416889c161f0 to your computer and use it in GitHub Desktop.
autorebase all feature branches onto main
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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