From 71bb30eec88c4903930c1fb5a765ec2b72126886 Mon Sep 17 00:00:00 2001 From: Bertram Gilfoyle Date: Thu, 26 Feb 2026 10:01:51 +0000 Subject: [PATCH] git-stack: stacked branch management tool Manage dependent branch chains with: - Dependency graph tracking (.git-stack.json) - Preview/integration branch builds for testing - Ordered squash-merge landing with automatic rebase cascade - Visual dependency graph display --- README.md | 92 ++++++++++ git-stack | 501 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 593 insertions(+) create mode 100644 README.md create mode 100755 git-stack diff --git a/README.md b/README.md new file mode 100644 index 0000000..c054d0b --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# git-stack + +Manage stacked branch dependencies, build preview/integration branches, and land them as clean squashed commits — in order. + +Built for workflows where multiple agents (or humans) create dependent PR chains and you need to: +- **Test** the full stack merged together (preview builds) +- **Land** each branch as a single squashed commit in dependency order +- **Auto-rebase** downstream branches when an upstream branch lands + +## Install + +```bash +# Copy to somewhere in your PATH +cp git-stack /usr/local/bin/ +# Or just use it directly: ./git-stack +``` + +## Quick Start + +```bash +# In your git repo +git-stack init + +# Define your stack +git-stack add fake-mode # depends on main (default) +git-stack add feature-A -d fake-mode # depends on fake-mode +git-stack add feature-B -d fake-mode # depends on fake-mode +git-stack add feature-C -d feature-A # depends on feature-A + +# See the graph +git-stack graph +# main +# ├── fake-mode +# │ ├── feature-A +# │ │ └── feature-C +# │ └── feature-B + +# Check status (what's ready to land) +git-stack status + +# Build a preview branch for testing/demos +git-stack preview feature-C # merges fake-mode → feature-A → feature-C +git-stack preview --all # merges everything +git-stack preview --all --push # ...and push it + +# Land branches (bottom-up) +git-stack land fake-mode --push # squash-merge, rebase feature-A & feature-B +git-stack land feature-A --push # squash-merge, rebase feature-C +git-stack land feature-B --push +git-stack land feature-C --push +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `init` | Initialize `.git-stack.json` in the repo | +| `add [-d deps...]` | Add branch with dependencies (default: main) | +| `remove ` | Remove branch from stack | +| `status` | Show stack with merge readiness | +| `graph` | Print dependency tree | +| `preview [branch\|--all]` | Build integration branch for testing | +| `land ` | Squash-merge into main, rebase dependents | + +## Config + +Stored in `.git-stack.json` at the repo root: + +```json +{ + "branches": { + "fake-mode": ["main"], + "feature-A": ["fake-mode"], + "feature-B": ["fake-mode"], + "feature-C": ["feature-A"] + } +} +``` + +## How Landing Works + +1. Verifies all dependencies of the target branch are already in main +2. Squash-merges the branch into main (single commit) +3. Rebases all branches that depended on it onto new main +4. Updates `.git-stack.json` — removes landed branch, rewires deps to main +5. Optionally pushes main + force-pushes rebased branches (`--push`) + +## Requirements + +- Python 3.8+ +- Git +- No external dependencies diff --git a/git-stack b/git-stack new file mode 100755 index 0000000..241d504 --- /dev/null +++ b/git-stack @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""git-stack: Manage stacked branch dependencies, preview builds, and ordered landing.""" + +import argparse +import json +import os +import subprocess +import sys +from collections import defaultdict +from pathlib import Path + +CONFIG_FILE = ".git-stack.json" + + +def run_git(*args, check=True, capture=True, **kwargs): + """Run a git command and return stdout.""" + cmd = ["git"] + list(args) + if capture: + r = subprocess.run(cmd, capture_output=True, text=True, check=check, **kwargs) + return r.stdout.strip() + else: + return subprocess.run(cmd, check=check, **kwargs) + + +def git_root(): + return run_git("rev-parse", "--show-toplevel") + + +def config_path(): + return os.path.join(git_root(), CONFIG_FILE) + + +def load_config(): + p = config_path() + if not os.path.exists(p): + return {"branches": {}} + with open(p) as f: + return json.load(f) + + +def save_config(cfg): + p = config_path() + with open(p, "w") as f: + json.dump(cfg, f, indent=2) + f.write("\n") + + +def topo_sort(branches, targets=None): + """Topological sort of branch dependencies. If targets given, only include ancestors of targets.""" + if targets: + # collect all ancestors of targets + needed = set() + def collect(b): + if b in needed or b not in branches: + return + needed.add(b) + for dep in branches[b]: + collect(dep) + for t in targets: + collect(t) + else: + needed = set(branches.keys()) + + order = [] + visited = set() + visiting = set() + + def visit(b): + if b in visited or b not in branches: + return + if b in visiting: + print(f"ERROR: Circular dependency involving '{b}'", file=sys.stderr) + sys.exit(1) + visiting.add(b) + for dep in branches[b]: + visit(dep) + visiting.remove(b) + visited.add(b) + if b in needed: + order.append(b) + + for b in branches: + visit(b) + return order + + +def get_merged_branches(branches): + """Return set of branches whose content is already in main.""" + merged = set() + for b in branches: + try: + # Check if main contains all commits from this branch's dependency + merge_base = run_git("merge-base", "main", b, check=False) + branch_sha = run_git("rev-parse", b, check=False) + if not merge_base or not branch_sha: + continue + # Check if the diff between main and the branch is empty after merge-base + diff = run_git("diff", f"main...{b}", "--stat", check=False) + if not diff: + merged.add(b) + except Exception: + pass + return merged + + +def current_branch(): + return run_git("rev-parse", "--abbrev-ref", "HEAD") + + +# --- Commands --- + +def cmd_init(args): + """Initialize git-stack config in the current repo.""" + p = config_path() + if os.path.exists(p) and not args.force: + print(f"Config already exists at {p}. Use --force to overwrite.") + return + save_config({"branches": {}}) + print(f"Initialized {CONFIG_FILE}") + + +def cmd_add(args): + """Add a branch dependency.""" + cfg = load_config() + branch = args.branch + depends_on = args.depends_on or ["main"] + + cfg["branches"][branch] = depends_on + save_config(cfg) + print(f"Added '{branch}' depending on: {', '.join(depends_on)}") + + +def cmd_remove(args): + """Remove a branch from the stack.""" + cfg = load_config() + branch = args.branch + if branch not in cfg["branches"]: + print(f"Branch '{branch}' not in stack.") + return + + # Check if anything depends on it + dependents = [b for b, deps in cfg["branches"].items() if branch in deps] + if dependents and not args.force: + print(f"Cannot remove '{branch}': depended on by {', '.join(dependents)}") + print("Use --force to remove anyway (will leave dangling deps).") + return + + del cfg["branches"][branch] + save_config(cfg) + print(f"Removed '{branch}' from stack.") + + +def cmd_status(args): + """Show the dependency graph and what's ready to land.""" + cfg = load_config() + branches = cfg["branches"] + + if not branches: + print("No branches in stack. Use 'git-stack add ' to add one.") + return + + order = topo_sort(branches) + + # Check which branches exist + existing = set() + for b in branches: + try: + run_git("rev-parse", "--verify", b) + existing.add(b) + except subprocess.CalledProcessError: + pass + + # Find what's landable (all deps are either "main" or already merged) + merged = set() + for b in order: + deps = branches[b] + all_deps_met = all(d == "main" or d in merged for d in deps) + # check if branch is already merged into main + if b in existing: + try: + cherry = run_git("cherry", "main", b) + if not cherry: # all commits are in main + merged.add(b) + except subprocess.CalledProcessError: + pass + + print("Stack:") + print() + for b in order: + deps = branches[b] + exists = b in existing + is_merged = b in merged + deps_met = all(d == "main" or d in merged for d in deps) + + status = "" + if is_merged: + status = " ✅ merged" + elif not exists: + status = " ⚠️ branch missing" + elif deps_met: + status = " 🟢 ready to land" + else: + unmet = [d for d in deps if d != "main" and d not in merged] + status = f" 🔴 waiting on: {', '.join(unmet)}" + + dep_str = ", ".join(deps) + print(f" {b} (← {dep_str}){status}") + + +def cmd_preview(args): + """Build a preview/integration branch.""" + cfg = load_config() + branches = cfg["branches"] + + if args.all: + targets = list(branches.keys()) + preview_name = "preview/all" + else: + if not args.branch: + print("Specify a branch or use --all") + return + targets = [args.branch] + preview_name = f"preview/{args.branch}" + + if args.name: + preview_name = args.name + + order = topo_sort(branches, targets) + + if not order: + print("No branches to preview.") + return + + base = args.base or "main" + + print(f"Building preview branch '{preview_name}' from '{base}'") + print(f"Merge order: {' → '.join(order)}") + print() + + # Save current branch to restore later + orig = current_branch() + + try: + # Create preview branch from base + run_git("checkout", base) + # Delete existing preview branch if it exists + try: + run_git("branch", "-D", preview_name) + except subprocess.CalledProcessError: + pass + run_git("checkout", "-b", preview_name) + + for b in order: + print(f" Merging {b}...") + try: + run_git("merge", "--no-ff", b, "-m", f"Preview merge: {b}") + except subprocess.CalledProcessError: + print(f"\n ❌ Conflict merging '{b}'. Aborting.") + run_git("merge", "--abort", check=False) + run_git("checkout", orig) + run_git("branch", "-D", preview_name, check=False) + return + + print(f"\n✅ Preview branch '{preview_name}' ready.") + + if args.push: + print(f"Pushing {preview_name}...") + run_git("push", "--force", "origin", preview_name) + print("Pushed.") + + finally: + run_git("checkout", orig, check=False) + + +def cmd_land(args): + """Squash-merge a branch into main and rebase dependents.""" + cfg = load_config() + branches = cfg["branches"] + branch = args.branch + + if branch not in branches: + print(f"Branch '{branch}' not in stack.") + return + + # Check deps are met + deps = branches[branch] + for d in deps: + if d != "main" and d in branches: + # check if it's merged + try: + cherry = run_git("cherry", "main", d) + if cherry: + print(f"❌ Dependency '{d}' not yet merged into main.") + return + except subprocess.CalledProcessError: + print(f"❌ Cannot verify dependency '{d}'.") + return + + orig = current_branch() + + try: + # Squash merge + run_git("checkout", "main") + run_git("pull", "--ff-only", "origin", "main", check=False) + + print(f"Squash-merging '{branch}' into main...") + try: + run_git("merge", "--squash", branch) + except subprocess.CalledProcessError: + print(f"❌ Conflict squash-merging '{branch}'. Aborting.") + run_git("reset", "--hard", "HEAD") + run_git("checkout", orig, check=False) + return + + # Commit + msg = args.message or f"Land {branch} (squash)" + run_git("commit", "-m", msg) + print(f"✅ Landed '{branch}' as single commit on main.") + + if args.push: + print("Pushing main...") + run_git("push", "origin", "main") + + # Rebase all transitive dependents of the landed branch. + # Walk the dependency graph to find everything downstream, + # then rebase in topological order using --onto with old tips + # as cut points. + def find_descendants(root, branches_dict): + """Find all branches that transitively depend on root.""" + desc = set() + def walk(b): + for name, deps in branches_dict.items(): + if b in deps and name not in desc: + desc.add(name) + walk(name) + walk(root) + return desc + + descendants = find_descendants(branch, cfg["branches"]) + if descendants: + # Save old tips before rebasing + old_tips = {} + for b in descendants: + try: + old_tips[b] = run_git("rev-parse", b) + except subprocess.CalledProcessError: + pass + old_tips[branch] = run_git("rev-parse", branch) + + # Get topological order of just the descendants + desc_branches = {b: cfg["branches"][b] for b in descendants if b in cfg["branches"]} + rebase_order = topo_sort(desc_branches) + + # Stash any dirty working tree state (config file changes, etc.) + run_git("stash", "--include-untracked", check=False) + + print(f"\nRebasing downstream: {', '.join(rebase_order)}") + for b in rebase_order: + deps = cfg["branches"][b] + parent = deps[0] + # Determine new base and old parent tip for cut point + if parent == branch: + new_base = "main" + old_parent = old_tips[branch] + elif parent in old_tips: + new_base = parent + old_parent = old_tips[parent] + else: + new_base = "main" + old_parent = old_tips.get(branch, "main") + + print(f" {b} --onto {new_base} (cut at {old_parent[:8]})...") + try: + run_git("rebase", "--onto", new_base, old_parent, b) + if args.push: + run_git("push", "--force", "origin", b) + # Update old_tips so children of this branch use new position + old_tips[b] = run_git("rev-parse", b) + print(f" ✅ {b} rebased.") + except subprocess.CalledProcessError: + print(f" ❌ Conflict rebasing {b}. Resolve manually.") + run_git("rebase", "--abort", check=False) + + # Restore stashed state if we stashed earlier + if descendants: + run_git("checkout", "main", check=False) + run_git("stash", "pop", check=False) + + # Update config: remove landed branch, update deps + del cfg["branches"][branch] + for b in cfg["branches"]: + if branch in cfg["branches"][b]: + cfg["branches"][b] = [ + "main" if d == branch else d + for d in cfg["branches"][b] + ] + # deduplicate + seen = set() + cfg["branches"][b] = [ + d for d in cfg["branches"][b] + if d not in seen and not seen.add(d) + ] + save_config(cfg) + print(f"\nUpdated stack config (removed '{branch}', updated deps).") + + finally: + run_git("checkout", orig, check=False) + + +def cmd_graph(args): + """Print a visual dependency graph.""" + cfg = load_config() + branches = cfg["branches"] + + if not branches: + print("No branches in stack.") + return + + # Build reverse map (parent -> children) + children = defaultdict(list) + roots = [] + for b, deps in branches.items(): + for d in deps: + children[d].append(b) + if all(d == "main" or d not in branches for d in deps): + roots.append(b) + + def print_tree(node, prefix="", is_last=True, is_root=False): + connector = "" if is_root else ("└── " if is_last else "├── ") + print(f"{prefix}{connector}{node}") + child_prefix = prefix + ("" if is_root else (" " if is_last else "│ ")) + kids = [c for c in children.get(node, []) if c in branches] + for i, child in enumerate(kids): + print_tree(child, child_prefix, i == len(kids) - 1) + + print("main") + for i, root in enumerate(roots): + print_tree(root, "", i == len(roots) - 1) + + +def main(): + parser = argparse.ArgumentParser( + prog="git-stack", + description="Manage stacked branch dependencies, preview builds, and ordered landing.", + ) + sub = parser.add_subparsers(dest="command") + + # init + p = sub.add_parser("init", help="Initialize git-stack in this repo") + p.add_argument("--force", action="store_true") + + # add + p = sub.add_parser("add", help="Add a branch to the stack") + p.add_argument("branch") + p.add_argument("--depends-on", "-d", nargs="+", help="Parent branches (default: main)") + + # remove + p = sub.add_parser("remove", help="Remove a branch from the stack") + p.add_argument("branch") + p.add_argument("--force", action="store_true") + + # status + sub.add_parser("status", help="Show stack status") + + # graph + sub.add_parser("graph", help="Show dependency graph") + + # preview + p = sub.add_parser("preview", help="Build a preview/integration branch") + p.add_argument("branch", nargs="?") + p.add_argument("--all", action="store_true", help="Preview entire stack") + p.add_argument("--name", help="Custom preview branch name") + p.add_argument("--base", default="main", help="Base branch (default: main)") + p.add_argument("--push", action="store_true", help="Push preview branch") + + # land + p = sub.add_parser("land", help="Squash-merge a branch into main") + p.add_argument("branch") + p.add_argument("--message", "-m", help="Commit message") + p.add_argument("--push", action="store_true", help="Push main and rebased branches") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + commands = { + "init": cmd_init, + "add": cmd_add, + "remove": cmd_remove, + "status": cmd_status, + "graph": cmd_graph, + "preview": cmd_preview, + "land": cmd_land, + } + commands[args.command](args) + + +if __name__ == "__main__": + main()