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