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:
commit
71bb30eec8
2 changed files with 593 additions and 0 deletions
92
README.md
Normal file
92
README.md
Normal 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
501
git-stack
Executable 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()
|
||||
Loading…
Reference in a new issue