874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
299 lines
14 KiB
Python
299 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Manage parallel parity work: one worktree and branch per area, one integration branch.
|
|
|
|
python3 tools/supervise.py status # every worktree: branch, progress, last gate
|
|
python3 tools/supervise.py new dispensers # worktree ../dispensers on parity/dispensers, from the integration branch
|
|
python3 tools/supervise.py save [AREA ...] # commit and push unfinished work (before stopping agents)
|
|
python3 tools/supervise.py check [BRANCH ...] # which branches merge cleanly, alone and together (seconds)
|
|
python3 tools/supervise.py integrate parity/dispensers # merge into the integration branch if the gate passes
|
|
python3 tools/supervise.py integrate parity/a parity/b # the largest set that merges cleanly, one gate
|
|
python3 tools/supervise.py integrate --ready # every parity/* branch with new commits
|
|
|
|
The integration branch (`parity-base` by default) is checked out in its own
|
|
worktree; `integrate` merges there, runs tools/gate.py and pushes the
|
|
integration branch only when the gate passes, allowing only the harnesses
|
|
that already fail on the integration branch to keep failing. Otherwise it undoes the merge
|
|
and leaves the branch for its owner to fix.
|
|
|
|
`check` and `integrate` first trial-merge the candidates in the object database
|
|
(git merge-tree; no worktree is touched): each alone, every pair, then the
|
|
largest set without conflicts, merged in turn. `integrate` merges that set and
|
|
gates it once (formatting, lints and tests come before the long harness run).
|
|
If the gate fails on several branches, it tries each half in turn, so the
|
|
branches that pass still land. Worktrees live next to the
|
|
integration worktree and share its vanilla data: their `.local/` entries
|
|
link to `shared-local/` in the same directory.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def git(*args, cwd=None, check=True):
|
|
cwd = cwd or ROOT
|
|
result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
if check and result.returncode:
|
|
raise SystemExit(f"git {' '.join(args)} failed in {cwd}:\n{result.stderr.strip()}")
|
|
return result.stdout.strip()
|
|
|
|
|
|
def worktrees():
|
|
found, current = [], {}
|
|
for line in git("worktree", "list", "--porcelain").splitlines() + [""]:
|
|
if not line:
|
|
if current:
|
|
found.append(current)
|
|
current = {}
|
|
elif line.startswith("worktree "):
|
|
current["path"] = Path(line[len("worktree "):])
|
|
elif line.startswith("branch "):
|
|
current["branch"] = line[len("branch refs/heads/"):]
|
|
return found
|
|
|
|
|
|
def integration(base):
|
|
for tree in worktrees():
|
|
if tree.get("branch") == base:
|
|
return tree["path"]
|
|
raise SystemExit(f"No worktree has {base} checked out; create one with: git worktree add <path> {base}")
|
|
|
|
|
|
def count(range_, cwd):
|
|
return int(git("rev-list", "--count", range_, cwd=cwd, check=False) or 0)
|
|
|
|
|
|
def status(args):
|
|
base_tree = integration(args.base)
|
|
print(f"{'worktree':<16} {'branch':<26} {'ahead':>5} {'behind':>6} {'dirty':>5} {'unpushed':>8} gate")
|
|
for tree in worktrees():
|
|
path, branch = tree["path"], tree.get("branch", "(detached)")
|
|
if path.parent != base_tree.parent and path != base_tree:
|
|
continue
|
|
dirty = len(git("status", "--porcelain", cwd=path).splitlines())
|
|
remote = f"origin/{args.push_to if branch == args.base else branch}"
|
|
unpushed = count(f"{remote}..HEAD", path) if git("rev-parse", "--verify", "--quiet", remote, cwd=path, check=False) else "new"
|
|
gate = path / ".local" / "gate.json"
|
|
verdict = "never run"
|
|
if gate.exists():
|
|
result = json.loads(gate.read_text())
|
|
verdict = ("passed" if result["passed"] else "failed") + ("" if result["parity"] else " (no harnesses)")
|
|
if result["commit"] != git("rev-parse", "HEAD", cwd=path) or result["dirty"]:
|
|
verdict += ", older commit"
|
|
print(f"{path.name:<16} {branch:<26} {count(f'{args.base}..HEAD', path):>5} {count(f'HEAD..{args.base}', path):>6}"
|
|
f" {dirty:>5} {unpushed:>8} {verdict}")
|
|
|
|
|
|
def new(args):
|
|
base_tree = integration(args.base)
|
|
path = base_tree.parent / args.area
|
|
branch = f"parity/{args.area}"
|
|
if path.exists():
|
|
raise SystemExit(f"{path} already exists")
|
|
git("worktree", "add", "-b", branch, str(path), args.base)
|
|
shared = base_tree.parent / "shared-local"
|
|
(path / ".local").mkdir()
|
|
# Link the vanilla data, not the shared runner and datafix state beside it.
|
|
for entry in sorted(shared.iterdir()) if shared.is_dir() else []:
|
|
if entry.is_dir() and entry.name not in {"parity-shared", "datafix"}:
|
|
(path / ".local" / entry.name).symlink_to(entry)
|
|
print(f"{path} on {branch}, from {args.base} at {git('rev-parse', '--short', 'HEAD', cwd=path)}")
|
|
|
|
|
|
def save(args):
|
|
base_tree = integration(args.base)
|
|
for tree in worktrees():
|
|
path, branch = tree["path"], tree.get("branch")
|
|
if not branch or not branch.startswith("parity/") or path.parent != base_tree.parent:
|
|
continue
|
|
if args.areas and path.name not in args.areas:
|
|
continue
|
|
if git("status", "--porcelain", cwd=path):
|
|
git("add", "-A", cwd=path)
|
|
git("commit", "-q", "-m", f"WIP: unfinished {path.name} parity work\n\nWork in progress saved when its agent "
|
|
"stopped; may not build or pass its harnesses yet.\n\n"
|
|
"Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>", cwd=path)
|
|
print(f"{path.name}: committed unfinished work")
|
|
git("push", "-q", "origin", f"HEAD:{branch}", cwd=path)
|
|
print(f"{path.name}: {branch} pushed at {git('rev-parse', '--short', 'HEAD', cwd=path)}")
|
|
|
|
|
|
def trial_merge(onto, branch):
|
|
"""Merge branch onto the commit onto without touching any worktree: (merged commit or None, conflicting paths)."""
|
|
result = subprocess.run(["git", "merge-tree", "--write-tree", "--name-only", "--no-messages", onto, branch],
|
|
cwd=ROOT, capture_output=True, text=True)
|
|
lines = result.stdout.split()
|
|
if result.returncode == 0:
|
|
return git("commit-tree", lines[0], "-p", onto, "-p", branch, "-m", f"Trial merge of {branch}"), []
|
|
if result.returncode == 1:
|
|
return None, lines[1:]
|
|
raise SystemExit(f"git merge-tree {onto} {branch} failed:\n{result.stderr.strip()}")
|
|
|
|
|
|
def largest_compatible(branches, conflicts):
|
|
"""The largest subset of branches with no conflicting pair (earlier branches win ties)."""
|
|
best = []
|
|
|
|
def search(chosen, index):
|
|
nonlocal best
|
|
if len(chosen) + len(branches) - index <= len(best):
|
|
return
|
|
if index == len(branches):
|
|
best = list(chosen)
|
|
return
|
|
branch = branches[index]
|
|
if not any(frozenset((branch, other)) in conflicts for other in chosen):
|
|
search(chosen + [branch], index + 1)
|
|
search(chosen, index + 1)
|
|
|
|
search([], 0)
|
|
return best
|
|
|
|
|
|
def plan(base, branches, verbose=True):
|
|
"""Trial-merge the branches into base: the largest set that merges cleanly, in order, and its merged commit."""
|
|
say = print if verbose else (lambda *_: None)
|
|
started = time.monotonic()
|
|
base_commit = git("rev-parse", base)
|
|
candidates, alone = [], {}
|
|
for branch in branches:
|
|
ahead = count(f"{base_commit}..{branch}", ROOT)
|
|
if not ahead:
|
|
say(f" {branch:<28} nothing new")
|
|
continue
|
|
merged, files = trial_merge(base_commit, branch)
|
|
alone[branch] = merged
|
|
if merged:
|
|
candidates.append(branch)
|
|
say(f" {branch:<28} {ahead:>3} commits, merges cleanly")
|
|
else:
|
|
say(f" {branch:<28} {ahead:>3} commits, CONFLICTS with {base}: {', '.join(files)}")
|
|
conflicts = {}
|
|
for index, first in enumerate(candidates):
|
|
for second in candidates[index + 1:]:
|
|
merged, files = trial_merge(alone[first], second)
|
|
if not merged:
|
|
conflicts[frozenset((first, second))] = files
|
|
say(f" {first} and {second} conflict: {', '.join(files)}")
|
|
chosen, head = [], base_commit
|
|
for branch in largest_compatible(candidates, conflicts):
|
|
merged, files = trial_merge(head, branch)
|
|
if merged:
|
|
chosen.append(branch)
|
|
head = merged
|
|
else:
|
|
say(f" {branch} conflicts with {', '.join(chosen)} together: {', '.join(files)}")
|
|
say(f"{len(chosen)} of {len(candidates) + sum(1 for b in alone if not alone[b])} branches merge cleanly together"
|
|
f"{': ' + ', '.join(chosen) if chosen else ''} (checked in {time.monotonic() - started:.1f}s)")
|
|
return chosen, head
|
|
|
|
|
|
def ready_branches(base):
|
|
"""Every parity/* branch with commits the integration branch lacks."""
|
|
names = git("for-each-ref", "--format=%(refname:short)", "refs/heads/parity/").splitlines()
|
|
return [name for name in names if name != base and count(f"{base}..{name}", ROOT)]
|
|
|
|
|
|
def check(args):
|
|
branches = args.branches or ready_branches(args.base)
|
|
print(f"Trial merges into {args.base} at {git('rev-parse', '--short', args.base)} (committed work only):")
|
|
plan(args.base, branches)
|
|
|
|
|
|
def integrate(args):
|
|
if not args.branches and not args.ready:
|
|
raise SystemExit("Name the branches to integrate, or pass --ready for every parity/* branch with new commits.")
|
|
print(f"Trial merges into {args.base}:")
|
|
chosen, _ = plan(args.base, args.branches or ready_branches(args.base))
|
|
if not chosen:
|
|
print("Nothing to integrate.")
|
|
return 2 if args.branches else 0
|
|
landed, failed = integrate_set(args, chosen)
|
|
print(f"Integrated: {', '.join(landed) or 'nothing'}. Not integrated: {', '.join(failed) or 'none'}.")
|
|
return 1 if failed else 0
|
|
|
|
|
|
def integrate_set(args, branches):
|
|
"""Merge and gate branches together; if the gate fails, try each half. Returns (integrated, rejected)."""
|
|
landed = integrate_once(args, branches)
|
|
if landed is not None:
|
|
return landed, []
|
|
if len(branches) == 1 or args.no_bisect:
|
|
return [], branches
|
|
half = len(branches) // 2
|
|
print(f"Trying {', '.join(branches[:half])} and {', '.join(branches[half:])} apart.")
|
|
first, first_failed = integrate_set(args, branches[:half])
|
|
second, second_failed = integrate_set(args, branches[half:])
|
|
return first + second, first_failed + second_failed
|
|
|
|
|
|
def integrate_once(args, branches):
|
|
"""Merge branches into the integration worktree and gate them: the branches integrated, or None if the gate failed."""
|
|
args = argparse.Namespace(**{**vars(args), "branches": branches})
|
|
base_tree = integration(args.base)
|
|
if git("status", "--porcelain", "--untracked-files=no", cwd=base_tree):
|
|
raise SystemExit(f"{base_tree} has uncommitted changes; commit or stash them first")
|
|
before = git("rev-parse", "HEAD", cwd=base_tree)
|
|
state = base_tree / ".local/parity-runner/state.json"
|
|
known = sorted(name for name, record in json.loads(state.read_text())["harnesses"].items()
|
|
if record["status"] != "pass") if state.exists() else []
|
|
for branch in args.branches:
|
|
merge = subprocess.run(["git", "merge", "--no-ff", "--no-edit", branch], cwd=base_tree, capture_output=True, text=True)
|
|
if merge.returncode:
|
|
conflicts = git("diff", "--name-only", "--diff-filter=U", cwd=base_tree, check=False)
|
|
git("merge", "--abort", cwd=base_tree, check=False)
|
|
git("reset", "--hard", before, cwd=base_tree)
|
|
print(f"{branch} does not merge cleanly into {args.base}. Conflicts:\n{conflicts or merge.stdout + merge.stderr}")
|
|
return None
|
|
names = ", ".join(args.branches)
|
|
if git("rev-parse", "HEAD", cwd=base_tree) == before:
|
|
print(f"{names}: nothing new for {args.base}.")
|
|
return []
|
|
# The flake check covers what these branches bring: harnesses changed since the current integration branch.
|
|
gate = [sys.executable, "tools/gate.py", "--known-failures", ",".join(known), "--flake-base", before]
|
|
gate += ["-j", str(args.jobs)] if args.jobs is not None else []
|
|
gate += ["--no-parity"] if args.no_parity else []
|
|
if subprocess.run(gate, cwd=base_tree).returncode:
|
|
git("reset", "--hard", before, cwd=base_tree)
|
|
print(f"The gate failed, so {names} were not integrated; {args.base} is back at {before[:9]}.")
|
|
return None
|
|
git("push", "-q", "origin", f"{args.base}:{args.push_to}", cwd=base_tree)
|
|
print(f"Integrated {names}; {args.base} pushed to origin/{args.push_to} at {git('rev-parse', '--short', 'HEAD', cwd=base_tree)}.")
|
|
return list(args.branches)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--base", default="parity-base", help="the integration branch")
|
|
parser.add_argument("--push-to", default="t3/achieve-full-parity", help="remote branch the integration branch is pushed to")
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
commands.add_parser("status").set_defaults(run=status)
|
|
command = commands.add_parser("new")
|
|
command.add_argument("area")
|
|
command.set_defaults(run=new)
|
|
command = commands.add_parser("save")
|
|
command.add_argument("areas", nargs="*")
|
|
command.set_defaults(run=save)
|
|
command = commands.add_parser("check")
|
|
command.add_argument("branches", nargs="*", help="default: every parity/* branch with new commits")
|
|
command.set_defaults(run=check)
|
|
command = commands.add_parser("integrate")
|
|
command.add_argument("branches", nargs="*", help="the largest set of them that merges cleanly is merged in order, "
|
|
"then gated together")
|
|
command.add_argument("--ready", action="store_true", help="every parity/* branch with new commits")
|
|
command.add_argument("--no-bisect", action="store_true", help="if the gate fails, don't retry halves of the set")
|
|
command.add_argument("-j", "--jobs", type=int, help="harnesses at once on this machine (default: tools/parity.py's)")
|
|
command.add_argument("--no-parity", action="store_true", help="skip the vanilla harnesses in the gate")
|
|
command.set_defaults(run=integrate)
|
|
args = parser.parse_args()
|
|
return args.run(args) or 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|