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
This commit is contained in:
Bertram Gilfoyle 2026-02-26 10:01:51 +00:00
commit 71bb30eec8
2 changed files with 593 additions and 0 deletions

92
README.md Normal file
View file

@ -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 <branch> [-d deps...]` | Add branch with dependencies (default: main) |
| `remove <branch>` | Remove branch from stack |
| `status` | Show stack with merge readiness |
| `graph` | Print dependency tree |
| `preview [branch\|--all]` | Build integration branch for testing |
| `land <branch>` | 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

501
git-stack Executable file
View file

@ -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 <branch>' 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()