feat(kvb-git-hotfix): share hotfixes in the register and sync before every action

The Maintainer messaged every agent about every hotfix, waking each for a full turn. Hotfixes are now register rows hotfix/<slug> (repository identity by git common dir and normalized origin URL, branch, sha, PR, failing-line signature, targets, stage), written with hotfix.py add/update and queried only when an agent acts, never by cron: pending lists merged hotfixes missing from HEAD (filtered by targets) and open ones; match finds hotfixes whose signature fits a failing line, evaluated in one worker under a shared deadline; void tells gate.py run, before every target, when a merged hotfix missing from HEAD makes the rest of the run worthless (exit 3), the operator's one exception to never cutting a job short; sync fetches first, then under the worktree locks merges origin/<default> and every base up the funnel chain recorded with rasci.py set --base, refusing a detached or foreign branch, a diverged base or a busy worktree, aborting cleanly on a conflict.

gate.py gains hold, which runs any heavy command, deploys included, under the worktree lock, forwards signals, returns the command's code and exits 75 when busy; run holds the same lock and takes the sync lock shared. kvb-check-gate, kvb-ci-triage, kvb-org-organize (Peer protocol, capacity), kvb-org-coordinate and kvb-org-split follow; rasci gaps, wsjf and artifacts skip hotfix and operator rows. Three independent skeptic rounds found 34 defects (three of them high), all fixed with tests; negative controls fail against the earlier code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-04 17:07:23 +01:00
1 parent 713267dd15
commit b4c4cefadd
17 files changed
+2497 -115

No files matched your search

+291 -1
View File
@@ -1,17 +1,28 @@
"""Exercise gate.py: static `make test*` listing, runs judged per target, and the per-HEAD gate."""
"""Exercise gate.py: static `make test*` listing, runs judged per target, hotfix voids, and the per-HEAD gate."""
from __future__ import annotations
import contextlib
import fcntl
import importlib.util
import io
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
from typing import ClassVar
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gate.py"
HOTFIX = REPO_ROOT / "skills" / "kvb-git-hotfix" / "scripts" / "hotfix.py"
RUN_LOCK = "kvb-gate-run.lock"
SYNC_LOCK = "kvb-gate-sync.lock"
GREEN = {
"Makefile": (
"include mk/*.mk\n"
@@ -56,7 +67,28 @@ RED = {
"tracked.txt": "one\n",
}
USAGE = {"Makefile": "test:\n\t@true\nbuild:\n\t@true\n"}
VOID = {"Makefile": ".PHONY: test test-a test-b\ntest:\n\t@echo all\ntest-a:\n\t@echo a\ntest-b:\n\t@echo b\n"}
FAILED = ("--fail-regex", r"FAILED TARGETS: \S")
SYNC_HOLDER = "4242\thotfix.py sync --task t\n"
COUNTING_CHILD = (
"import os, signal, sys, time\n"
"got = []\n"
"signal.signal(signal.SIGINT, lambda signum, frame: got.append(signum))\n"
"with open(sys.argv[1] + '.tmp', 'w', encoding='utf-8') as out:\n"
" out.write(str(os.getpid()))\n"
"os.replace(sys.argv[1] + '.tmp', sys.argv[1])\n"
"while not got:\n"
" time.sleep(0.05)\n"
"time.sleep(0.5)\n"
"sys.exit(10 + len(got))\n"
)
def load_gate() -> object:
spec = importlib.util.spec_from_file_location("gate_under_test", SCRIPT)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
return gate
class GateCase(unittest.TestCase):
@@ -72,6 +104,7 @@ class GateCase(unittest.TestCase):
self.env = {
"PATH": "/usr/bin:/bin",
"HOME": tmp.name,
"AGENTS_COUNCIL_ROOT": str(self.root / "council"),
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.org",
@@ -199,6 +232,263 @@ class TestRedPath(GateCase):
self.assertEqual(rows["test"]["status"], "missing")
class TestHotfixes(GateCase):
files = VOID
def setUp(self):
super().setUp()
origin = self.root / "origin.git"
self._git("init", "-q", "--bare", "-b", "main", str(origin))
self._git("remote", "add", "origin", str(origin))
self._git("push", "-q", "-u", "origin", "main")
self._git("remote", "set-head", "origin", "main")
def hotfix(self, slug: str, status: str, *targets: str) -> str:
parent = "refs/remotes/origin/main" if status == "merged" else "HEAD"
sha = self._git("commit-tree", "HEAD^{tree}", "-p", parent, "-m", slug).strip()
if status == "merged":
self._git("push", "-q", "origin", f"{sha}:refs/heads/main")
flags = [part for target in targets for part in ("--target", target)]
result = subprocess.run(
[sys.executable, str(HOTFIX), "add", slug, "--repo", str(self.repo), "--branch", f"fix/{slug}",
"--sha", sha, "--signature", "boom", "--status", status, "--session", "t", *flags],
env=self.env, capture_output=True, text=True, check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
return sha
def results(self) -> dict[str, str]:
return {r["target"]: r["status"] for r in self.status(self.repo)[1]["targets"]}
def test_a_hotfix_for_any_remaining_target_voids_the_run_before_it_starts(self):
self.hotfix("lint", "pr")
self.hotfix("b", "merged", "test-b")
void = "void: merge main for hotfix b, then rerun\n"
result = self.run_targets("test-a", "test-b", extra=())
self.assertEqual((result.returncode, result.stdout), (3, void), result.stderr)
self.assertEqual(self.results(), {"test": "missing", "test-a": "missing", "test-b": "missing"})
self.assertEqual(self.run_targets("test-a", extra=()).returncode, 0)
self.assertEqual((self.run_targets("test", extra=()).returncode, self.results()["test"]), (3, "missing"))
self._git("merge", "-q", "--ff-only", "origin/main")
self.assertEqual(self.run_targets("test", "test-a", "test-b", extra=()).returncode, 0)
self.hotfix("any", "merged")
result = self.run_targets("test-a", extra=())
self.assertEqual((result.returncode, result.stdout), (3, "void: merge main for hotfix any, then rerun\n"))
def test_status_keeps_the_gate_open_while_a_merged_hotfix_is_missing(self):
self.assertEqual(self.run_targets("test", "test-a", "test-b", extra=()).returncode, 0)
self.hotfix("lint", "pr", "test-a")
self.assertEqual(self.last_status_line(), "gate: green")
self.hotfix("rollout", "merged", "deploy")
self.assertEqual(self.last_status_line(), "gate: green")
self.hotfix("db", "merged", "test-a")
code, status = self.status(self.repo)
self.assertEqual((code, status["gate"], status["open"]), (1, "open", ["missing hotfix: db"]))
self.assertEqual(self.last_status_line(), "gate: open (missing hotfix: db)")
def test_a_failing_or_missing_hotfix_check_warns_and_the_run_goes_on(self):
register = self.root / "council" / "rasci.json"
register.parent.mkdir()
legacy = {"stage": "merged", "repo": str(self.root / "gone"), "branch": "fix/legacy", "sha": "0" * 40,
"signature": "boom", "targets": [], "status": "done", "roles": {}}
register.write_text(json.dumps({"hotfix/legacy": legacy}), encoding="utf-8")
result = self.run_targets("test-a", extra=())
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("hotfix/legacy: repository unknown", result.stderr)
self.assertNotIn("going on without its check", result.stderr)
register.write_text("{", encoding="utf-8")
result = self.run_targets("test-a", extra=())
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("gate: hotfix.py void exited 2; going on without its check", result.stderr)
status = self.gate("status", "--repo", str(self.repo))
self.assertIn("gate: hotfix.py pending exited 2; going on without its check", status.stderr)
skills = self.root / "lone" / "skills"
shutil.copytree(SCRIPT.parents[1], skills / "kvb-check-gate", ignore=shutil.ignore_patterns("__pycache__"))
lone = subprocess.run(
[sys.executable, str(skills / "kvb-check-gate" / "scripts" / "gate.py"), "run", "--repo", str(self.repo),
"--target", "test-b", "--log-dir", str(self.logs)],
env=self.env, capture_output=True, text=True, check=False,
)
self.assertEqual(lone.returncode, 0, lone.stderr)
self.assertIn("kvb-git-hotfix/scripts/hotfix.py is missing; merged hotfixes are not checked", lone.stderr)
self.assertEqual(self.results()["test-b"], "green")
def test_verdict_lines_count_even_when_hotfix_exits_non_zero(self):
gate = load_gate()
stub = self.root / "stub.py"
gate.HOTFIX = stub
for printed, slugs, warned in (("void\thotfix/x\tabc\tall targets\n", ["x"], False), ("", [], True)):
with self.subTest(printed=printed):
stub.write_text(f"import sys\nsys.stdout.write({printed!r})\nsys.exit(2)\n", encoding="utf-8")
with contextlib.redirect_stderr(io.StringIO()) as err:
self.assertEqual(gate.voiding_hotfixes(self.repo, ["test-a"]), slugs)
self.assertEqual("hotfix.py void exited 2; going on without its check" in err.getvalue(), warned)
class TestWorktreeLock(GateCase):
files = VOID
def lock(self, name: str) -> Path:
return Path(self._git("rev-parse", "--absolute-git-dir").strip()) / name
def lock_free(self) -> bool:
with open(self.lock(RUN_LOCK), "a", encoding="utf-8") as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return False
return True
def test_a_busy_worktree_makes_run_and_hold_exit_75_naming_the_holder(self):
with open(self.lock(RUN_LOCK), "a", encoding="utf-8") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
lock.write("4242\tgate.py hold -- make deploy\n")
lock.flush()
run = self.run_targets("test-a", extra=())
hold = self.gate("hold", "--repo", str(self.repo), "--", "true")
for result in (run, hold):
with self.subTest(command=result.args[2]):
self.assertEqual(result.returncode, 75, result.stderr)
self.assertIn(f"another gate.py run or hold holds {self.repo} (pid 4242: gate.py hold -- make deploy)",
result.stderr)
self.assertEqual(self.run_targets("test-a", extra=()).returncode, 0)
self.assertIn(f"\tgate.py run --repo {self.repo} --target test-a\n",
self.lock(RUN_LOCK).read_text(encoding="utf-8"))
def test_hold_passes_the_commands_own_exit_codes_through(self):
for code in (1, 2, 3, 75):
with self.subTest(code=code):
result = self.gate("hold", "--repo", str(self.repo), "--", "sh", "-c", f"exit {code}")
self.assertEqual(result.returncode, code)
def test_hold_forwards_signals_to_the_command_and_waits_for_it(self):
for name, signum, group, code in (
("SIGINT to the process group, as Ctrl-C sends it", signal.SIGINT, True, 11),
("SIGTERM to hold only", signal.SIGTERM, False, 128 + signal.SIGTERM),
("SIGHUP to hold only", signal.SIGHUP, False, 128 + signal.SIGHUP),
):
with self.subTest(name):
pid_file = self.root / f"child-{signum}.pid"
holder = subprocess.Popen(
[sys.executable, str(SCRIPT), "hold", "--repo", str(self.repo), "--",
sys.executable, "-c", COUNTING_CHILD, str(pid_file)],
env=self.env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, start_new_session=True,
)
self.addCleanup(holder.stderr.close)
self.addCleanup(holder.wait)
self.addCleanup(holder.kill)
deadline = time.monotonic() + 30
while not pid_file.exists():
self.assertIsNone(holder.poll())
self.assertLess(time.monotonic(), deadline)
time.sleep(0.05)
child = int(pid_file.read_text(encoding="utf-8"))
self.addCleanup(self.kill, child)
(os.killpg if group else os.kill)(holder.pid, signum)
returned = holder.wait(timeout=30)
self.kill(child)
err = holder.stderr.read()
self.assertEqual(returned, code, err)
self.assertNotIn("Traceback", err)
self.assertTrue(self.lock_free())
def test_hold_runs_the_command_in_the_worktree_under_the_lock(self):
probe = (
"import fcntl, os, sys\n"
"with open(sys.argv[1], 'a', encoding='utf-8') as lock:\n"
" try:\n"
" fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)\n"
" except BlockingIOError:\n"
" print('held', os.getcwd())\n"
"sys.exit(5)\n"
)
result = self.gate("hold", "--repo", str(self.repo), "--", sys.executable, "-c", probe, str(self.lock(RUN_LOCK)))
self.assertEqual((result.returncode, result.stdout), (5, f"held {self.repo}\n"), result.stderr)
self.assertTrue(self.lock_free())
result = self.gate("hold", "--repo", str(self.repo), "--", "sh", "-c", "kill -TERM $$")
self.assertEqual(result.returncode, 128 + signal.SIGTERM)
self.assertEqual(self.gate("hold", "--repo", str(self.repo)).returncode, 2)
def test_an_orphan_of_a_hold_keeps_the_lock(self):
pid_file = self.root / "deploy.pid"
holder = subprocess.Popen(
[sys.executable, str(SCRIPT), "hold", "--repo", str(self.repo), "--",
"sh", "-c", f"echo $$ > {pid_file}.tmp; mv {pid_file}.tmp {pid_file}; exec sleep 60"],
env=self.env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
self.addCleanup(holder.wait)
self.addCleanup(holder.kill)
deadline = time.monotonic() + 30
while not pid_file.exists():
self.assertIsNone(holder.poll())
self.assertLess(time.monotonic(), deadline)
time.sleep(0.05)
deploy = int(pid_file.read_text(encoding="utf-8"))
self.addCleanup(self.kill, deploy)
holder.kill()
holder.wait()
result = self.run_targets("test-a", extra=())
self.assertEqual(result.returncode, 75)
self.assertIn(f"another gate.py run or hold holds {self.repo} (pid {holder.pid}: "
f"gate.py hold --repo {self.repo} -- sh -c", result.stderr)
self.kill(deploy)
deadline = time.monotonic() + 30
while not self.lock_free():
self.assertLess(time.monotonic(), deadline, "the killed deploy kept the lock")
time.sleep(0.05)
self.assertEqual(self.run_targets("test-a", extra=()).returncode, 0)
def kill(self, pid: int) -> None:
with contextlib.suppress(ProcessLookupError):
os.kill(pid, signal.SIGKILL)
def test_a_run_waits_for_a_running_sync_instead_of_failing(self):
with (
open(self.lock(SYNC_LOCK), "a", encoding="utf-8") as sync,
open(self.lock(RUN_LOCK), "a", encoding="utf-8") as lock,
):
fcntl.flock(sync, fcntl.LOCK_EX)
sync.write(SYNC_HOLDER)
sync.flush()
fcntl.flock(lock, fcntl.LOCK_EX)
run = subprocess.Popen(
[sys.executable, str(SCRIPT), "run", "--repo", str(self.repo), "--target", "test-a",
"--log-dir", str(self.logs)],
env=self.env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
self.addCleanup(run.wait)
self.addCleanup(run.kill)
self.assertEqual(
run.stderr.readline(),
f"gate: a hotfix.py sync merges in {self.repo} (pid 4242: hotfix.py sync --task t); waiting for it at "
f"most {load_gate().SYNC_WAIT_SECONDS} s\n",
)
out, err = run.communicate(timeout=60)
self.assertEqual(run.returncode, 0, err)
self.assertTrue(out.startswith("green\ttest-a\t"), out)
def test_a_sync_outlasting_the_wait_ends_it_with_75_naming_the_sync(self):
gate = load_gate()
gate.SYNC_WAIT_SECONDS = 1
with open(self.lock(SYNC_LOCK), "a", encoding="utf-8") as sync:
fcntl.flock(sync, fcntl.LOCK_EX)
sync.write(SYNC_HOLDER)
sync.flush()
with (
contextlib.redirect_stderr(io.StringIO()) as err,
self.assertRaises(SystemExit) as raised,
gate.worktree_lock(self.repo, ["gate.py", "hold"], []),
):
self.fail("the worktree lock was taken during a sync")
self.assertEqual(raised.exception.code, 75)
self.assertIn(f"a hotfix.py sync still holds {self.repo} after 1 s (pid 4242: hotfix.py sync --task t)",
err.getvalue())
self.assertTrue(self.lock_free())
class TestUsage(GateCase):
files = USAGE
+856
View File
@@ -0,0 +1,856 @@
"""Exercise kvb-git-hotfix: hotfix rows in the rasci register, pending, match, void and the funnel sync."""
from __future__ import annotations
import argparse
import contextlib
import fcntl
import importlib.util
import io
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import types
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
HOTFIX = REPO_ROOT / "skills" / "kvb-git-hotfix" / "scripts" / "hotfix.py"
RASCI = REPO_ROOT / "skills" / "kvb-org-rasci" / "scripts" / "rasci.py"
GATE = REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gate.py"
PROBE = (
"import fcntl, sys\n"
"with open(sys.argv[1], 'a', encoding='utf-8') as lock:\n"
" try:\n"
" fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)\n"
" except BlockingIOError:\n"
" print('held', sys.argv[2])\n"
" else:\n"
" print('free', sys.argv[2])\n"
)
SIGNATURE = r"connection refused .* port 5432"
RUN_LOCK = "kvb-gate-run.lock"
SYNC_LOCK = "kvb-gate-sync.lock"
IDENTITY = {
"GIT_AUTHOR_NAME": "Test",
"GIT_AUTHOR_EMAIL": "test@example.org",
"GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.org",
"GIT_CONFIG_NOSYSTEM": "1",
}
def load_hotfix() -> object:
spec = importlib.util.spec_from_file_location("hotfix", HOTFIX)
hotfix = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hotfix)
return hotfix
class TestOriginUrls(unittest.TestCase):
def test_spellings_of_one_repository_normalize_alike(self):
hotfix = load_hotfix()
top = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, top)
for url in (
"https://github.com/Org/Repo.git",
"https://GitHub.com/org/REPO/",
"git@github.com:Org/Repo.git",
"ssh://git@github.com:22/Org/Repo.git/",
"https://user@github.com/Org/Repo",
):
with self.subTest(url=url):
self.assertEqual(hotfix.normalized(url, top), "github.com/org/repo")
self.assertEqual(hotfix.normalized("https://GitLab.com/Group/Sub/App.git", top), "gitlab.com/group/sub/app")
self.assertEqual(hotfix.normalized("https://git.example.org/Org/Repo.git", top), "git.example.org/Org/Repo")
for url in ("file:///srv/repo.git", "/srv/repo.git", "/srv/repo/"):
with self.subTest(url=url):
self.assertEqual(hotfix.normalized(url, top), os.path.realpath("/srv/repo"))
parent = os.path.dirname(os.path.realpath(top))
self.assertEqual(hotfix.normalized("../up.git", top), os.path.join(parent, "up"))
self.assertEqual(hotfix.normalized("up", top), os.path.join(os.path.realpath(top), "up"))
self.assertEqual(hotfix.normalized("", top), "")
self.assertNotEqual(hotfix.normalized("git@github.com:Org/Other.git", top), "github.com/org/repo")
def test_differing_origins_mean_different_repositories_even_in_one_git_dir(self):
hotfix = load_hotfix()
self.assertFalse(hotfix.same(("/x/.git", "github.com/a/x"), ("/x/.git", "github.com/b/y")))
self.assertTrue(hotfix.same(("/x/.git", "github.com/a/x"), ("/y/.git", "github.com/a/x")))
self.assertTrue(hotfix.same(("/x/.git", ""), ("/x/.git", "github.com/a/x")))
self.assertFalse(hotfix.same(("/x/.git", ""), ("/y/.git", "github.com/a/x")))
class TestSignatures(unittest.TestCase):
def test_only_runaway_repeats_are_refused(self):
hotfix = load_hotfix()
for pattern in ("(a+)+", "(?:x|y+)*z", "((ab)*c)+", r"(\d{2,})+"):
with self.subTest(pattern=pattern):
self.assertEqual(hotfix.risk(pattern), hotfix.NESTED)
for pattern in ("(a|a)*b", "(ab|a|b)*c", r"(\w|\d\d)+$", "([^x]|y)*z"):
with self.subTest(pattern=pattern):
self.assertEqual(hotfix.risk(pattern), hotfix.OVERLAP)
for pattern in (
r"(\d{1,3}\.){3}\d{1,3}", r"(\d{1,3}\.)+\d+", r"(ab)+ timeout \d+", "(foo|bar)+ failed",
r"(\d|x)+", "(foo|fob)*x", "(?>a+)*b", "(a++)*b", "port (5432|5433) refused",
):
with self.subTest(pattern=pattern):
self.assertIsNone(hotfix.risk(pattern))
class TestBoundedChildren(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.base = Path(tmp.name).resolve()
self.hotfix = load_hotfix()
def test_match_exits_2_when_the_search_worker_fails_never_no_match(self):
rows = {"hotfix/x": {"stage": "pr", "signature": "x", "branch": "fix/x", "sha": "0" * 40, "origin": "o"}}
register = types.SimpleNamespace(load=lambda: rows)
for name, patch in (
("no interpreter", mock.patch.object(self.hotfix.sys, "executable", str(self.base / "absent"))),
("worker exits 3", mock.patch.object(self.hotfix, "SEARCH", "import sys; sys.exit(3)")),
("worker answers short", mock.patch.object(self.hotfix, "SEARCH", "print('ready')")),
):
with self.subTest(name):
out, err = io.StringIO(), io.StringIO()
with (
patch,
contextlib.redirect_stdout(out),
contextlib.redirect_stderr(err),
self.assertRaises(SystemExit) as raised,
):
self.hotfix.cmd_match(register, argparse.Namespace(line="x", repo=None))
self.assertEqual(raised.exception.code, 2, err.getvalue())
self.assertEqual(out.getvalue(), "")
self.assertIn("signature search worker", err.getvalue())
def test_a_fetch_never_prompts_and_stops_after_fetch_seconds(self):
marker = self.base / "prompt"
wrapper = self.base / "bin" / "git"
wrapper.parent.mkdir()
wrapper.write_text(
f"#!/bin/sh\nprintf '%s' \"$GIT_TERMINAL_PROMPT\" > {marker}\nexec sleep 30\n", encoding="utf-8"
)
wrapper.chmod(0o755)
began = time.monotonic()
with (
mock.patch.object(self.hotfix, "FETCH_SECONDS", 1),
mock.patch.dict(os.environ, {"PATH": f"{wrapper.parent}:/usr/bin:/bin"}),
):
result = self.hotfix.fetch(str(self.base))
self.assertLess(time.monotonic() - began, 20)
self.assertEqual((result.returncode, result.stderr), (124, "stopped after 1 s"))
self.assertEqual(marker.read_text(encoding="utf-8"), "0")
class TestHotfix(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.base = Path(tmp.name).resolve()
home = self.base / "home"
sessions = home / ".claude" / "sessions"
sessions.mkdir(parents=True)
(sessions / "s1.json").write_text(json.dumps({"sessionId": "s1", "name": "One"}), encoding="utf-8")
self.root = self.base / "council"
self.env = {
"PATH": "/usr/bin:/bin",
"HOME": str(home),
"AGENTS_COUNCIL_ROOT": str(self.root),
"CLAUDE_CODE_SESSION_ID": "s1",
"GIT_CEILING_DIRECTORIES": str(self.base),
**IDENTITY,
}
self.origin = self.base / "origin.git"
self.seed = self.published(self.origin, self.base / "seed", "app.txt")
self.work = self.base / "work"
self.git(self.base, "clone", "-q", str(self.origin), str(self.work))
self.feature = self.base / "feature"
self.git(self.work, "worktree", "add", "-q", "-b", "feature", str(self.feature), "origin/main")
self.git(self.work, "switch", "-q", "-c", "line")
self.commit(self.work, "line.txt", "line\n", "line work")
self.git(self.work, "switch", "-q", "-c", "sidetree")
self.commit(self.work, "side.txt", "side\n", "side work")
self.git(self.work, "switch", "-q", "line")
self.commit(self.work, "line2.txt", "more\n", "more line work")
self.git(self.work, "switch", "-q", "sidetree")
self.rasci("set", "work/line", "--branch", "line", "--base", "main", "--workdir", str(self.work))
self.rasci("set", "work/side", "--branch", "sidetree", "--base", "line", "--workdir", str(self.work))
self.rasci("set", "work/feature", "--branch", "feature", "--workdir", str(self.feature))
self.git(self.seed, "switch", "-q", "-c", "fix/db")
self.fix = self.commit(self.seed, "db.txt", "retry\n", "fix db")
self.git(self.seed, "switch", "-q", "main")
self.git(self.seed, "merge", "-q", "--ff-only", "fix/db")
self.git(self.seed, "push", "-q", "origin", "main")
self.git(self.seed, "switch", "-q", "-c", "fix/lint")
self.lint = self.commit(self.seed, "lint.txt", "ok\n", "fix lint")
self.git(self.seed, "switch", "-q", "main")
self.out("add", "db", *self.flags({"--branch": "fix/db", "--sha": self.fix, "--signature": SIGNATURE,
"--status": "merged", "--target": "test-deploy", "--pr": "12"}))
self.out("add", "lint", *self.flags({"--branch": "fix/lint", "--sha": self.lint[:10],
"--signature": r"ruff: E\d+", "--target": "test-lint"}))
self.assertEqual(self.out("update", "lint", "--status", "pr", "--pr", "13"), ["updated hotfix/lint"])
self.other = self.published(self.base / "other.git", self.base / "other", "o.txt")
self.loose = self.git(self.other, "commit-tree", "HEAD^{tree}", "-p", "HEAD", "-m", "loose").strip()
self.git(self.other, "push", "-q", "origin", f"{self.loose}:refs/heads/main")
self.out("add", "other", *self.flags({"--repo": str(self.other), "--sha": self.loose,
"--signature": "disk full", "--status": "merged"}))
def published(self, origin: Path, repo: Path, name: str) -> Path:
self.git(self.base, "init", "-q", "--bare", "-b", "main", str(origin))
repo.mkdir()
self.git(repo, "init", "-q", "-b", "main")
self.commit(repo, name, "one\n", "init")
self.git(repo, "remote", "add", "origin", str(origin))
self.git(repo, "push", "-q", "-u", "origin", "main")
self.git(repo, "remote", "set-head", "origin", "main")
return repo
def git(self, cwd: Path, *args: str) -> str:
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, text=True, check=True).stdout
def commit(self, repo: Path, name: str, text: str, message: str) -> str:
(repo / name).write_text(text, encoding="utf-8")
self.git(repo, "add", name)
self.git(repo, "commit", "-q", "-m", message)
return self.git(repo, "rev-parse", "HEAD").strip()
def flags(self, changes: dict[str, str | None]) -> list[str]:
values = {"--repo": str(self.seed), "--branch": "fix/x", "--sha": self.lint, "--signature": "x",
"--status": "staged", **changes}
return [part for flag, value in values.items() if value is not None for part in (flag, value)]
def hotfix(self, *args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(HOTFIX), *args],
capture_output=True, text=True, env=env or self.env, cwd=self.base, check=False,
)
def out(self, *args: str) -> list[str]:
result = self.hotfix(*args)
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout.splitlines()
def rasci(self, *args: str) -> list[str]:
result = subprocess.run(
[sys.executable, str(RASCI), *args],
capture_output=True, text=True, env=self.env, cwd=self.base, check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout.splitlines()
def rows(self) -> dict[str, dict]:
return json.loads((self.root / "rasci.json").read_text(encoding="utf-8"))
def write_rows(self, rows: dict[str, dict]) -> None:
(self.root / "rasci.json").write_text(json.dumps(rows), encoding="utf-8")
def sync(self, repo: Path, task: str, *extra: str) -> subprocess.CompletedProcess:
return self.hotfix("sync", "--repo", str(repo), "--task", task, *extra)
def merges(self, result: subprocess.CompletedProcess) -> list[list[str]]:
self.assertEqual(result.returncode, 0, result.stderr)
return [line.split("\t")[:2] for line in result.stdout.splitlines()]
def head(self, repo: Path) -> str:
return self.git(repo, "rev-parse", "HEAD").strip()
def contains(self, repo: Path, sha: str) -> bool:
return subprocess.run(
["git", "merge-base", "--is-ancestor", sha, "HEAD"], cwd=repo, env=self.env, capture_output=True,
check=False,
).returncode == 0
def test_rows_are_register_tasks_with_the_author_as_r_and_a(self):
rows = self.rows()
db = rows["hotfix/db"]
keys = ("common_dir", "origin", "branch", "sha", "pr", "signature", "targets", "stage", "status", "roles")
self.assertEqual(
{key: db[key] for key in keys},
{"common_dir": str(self.seed / ".git"), "origin": str(self.base / "origin"), "branch": "fix/db",
"sha": self.fix, "pr": 12, "signature": SIGNATURE, "targets": ["test-deploy"], "stage": "merged",
"status": "done", "roles": {"One": "RA"}},
)
self.assertNotIn("repo", db)
lint = rows["hotfix/lint"]
self.assertEqual((lint["sha"], lint["stage"], lint["status"], lint["pr"]), (self.lint, "pr", "running", 13))
self.assertEqual(rows["hotfix/other"]["targets"], [])
shown = "\n".join(self.rasci("show"))
self.assertIn("| hotfix/lint | running | - | One |", shown)
self.assertNotIn("hotfix/db", shown)
def test_pending_reports_missing_and_open_hotfixes_of_the_repository(self):
expected = [
f"missing (merge main)\thotfix/db\tmerged\tfix/db\t{self.fix}\t#12",
f"open, not merged\thotfix/lint\tpr\tfix/lint\t{self.lint}\t#13",
"hotfix: 1 missing, 1 open",
]
for repo in (self.work, self.feature):
with self.subTest(repo=repo.name):
result = self.hotfix("pending", "--repo", str(repo))
self.assertEqual((result.returncode, result.stdout.splitlines()), (1, expected), result.stderr)
self.assertEqual(self.out("pending", "--repo", str(self.seed)), [expected[1], "hotfix: 0 missing, 1 open"])
self.assertEqual(self.sync(self.work, "work/side").returncode, 0)
self.assertEqual(self.out("pending", "--repo", str(self.work)), [expected[1], "hotfix: 0 missing, 1 open"])
copy = self.base / "copy"
self.git(self.base, "clone", "-q", f"file://{self.origin}", str(copy))
self.assertEqual(self.out("pending", "--repo", str(copy)), [expected[1], "hotfix: 0 missing, 1 open"])
def test_a_row_outlives_the_worktree_it_was_recorded_in(self):
self.git(self.work, "fetch", "-q", "origin")
tree = self.base / "fixtree"
self.git(self.work, "worktree", "add", "-q", "-b", "fix/tree", str(tree), "origin/main")
sha = self.commit(tree, "tree.txt", "tree\n", "fix tree")
self.out("add", "tree", *self.flags({"--repo": str(tree), "--branch": "fix/tree", "--sha": sha,
"--target": "test-tree"}))
self.git(tree, "push", "-q", "origin", "HEAD:main")
self.git(self.work, "worktree", "remove", "--force", str(tree))
self.assertEqual(self.out("update", "tree", "--status", "merged", "--sha", sha), ["updated hotfix/tree"])
line = f"missing (merge main)\thotfix/tree\tmerged\tfix/tree\t{sha}\t-"
copy = self.base / "copy"
self.git(self.base, "clone", "-q", f"file://{self.origin}", str(copy))
self.git(copy, "reset", "-q", "--hard", "HEAD~1")
for repo in (self.work, self.feature, self.seed, copy):
with self.subTest(repo=repo.name):
result = self.hotfix("pending", "--repo", str(repo))
self.assertEqual((result.returncode, result.stderr), (1, ""))
self.assertIn(line, result.stdout.splitlines())
result = self.hotfix("void", "--repo", str(copy), "--target", "test-tree")
self.assertEqual((result.returncode, result.stdout), (3, f"void\thotfix/tree\t{sha}\ttest-tree\n"))
def test_a_row_of_an_unknown_repository_is_named_on_stderr_and_changes_no_exit_code(self):
rows = self.rows()
legacy = {key: value for key, value in rows["hotfix/db"].items() if key not in ("common_dir", "origin")}
rows["hotfix/legacy"] = {**legacy, "repo": str(self.base / "gone"), "targets": ["test-legacy"]}
self.write_rows(rows)
hint = (
"hotfix/legacy: repository unknown (no origin URL, git dir - gone); record it with "
f"`hotfix.py update legacy --status merged --sha {self.fix} --repo <clone>`"
)
result = self.hotfix("pending", "--repo", str(self.work))
self.assertEqual(result.returncode, 1)
self.assertIn(hint, result.stderr)
self.assertIn(f"missing (merge main)\thotfix/db\tmerged\tfix/db\t{self.fix}\t#12", result.stdout)
result = self.hotfix("void", "--repo", str(self.work), "--target", "test-lint")
self.assertEqual((result.returncode, result.stdout), (0, ""))
self.assertIn(hint, result.stderr)
self.assertEqual(self.hotfix("void", "--repo", str(self.work), "--target", "test-deploy").returncode, 3)
self.out("update", "legacy", "--status", "merged", "--sha", self.fix, "--repo", str(self.work))
result = self.hotfix("pending", "--repo", str(self.work))
self.assertEqual((result.returncode, result.stderr), (1, ""))
self.assertIn(f"missing (merge main)\thotfix/legacy\tmerged\tfix/db\t{self.fix}\t#12", result.stdout)
def test_update_resolves_the_sha_in_the_recorded_git_dir_or_an_explicit_clone(self):
lone = self.base / "lone"
self.git(self.base, "clone", "-q", str(self.origin), str(lone))
sha = self.commit(lone, "lone.txt", "lone\n", "fix lone")
self.out("add", "lone", *self.flags({"--repo": str(lone), "--branch": "fix/lone", "--sha": sha}))
self.git(lone, "push", "-q", "origin", "HEAD:main")
shutil.rmtree(lone)
self.assertEqual(self.out("update", "lone", "--status", "pr", "--pr", "7"), ["updated hotfix/lone"])
result = self.hotfix("update", "lone", "--status", "merged", "--sha", sha)
self.assertEqual(result.returncode, 2)
self.assertIn(f"hotfix/lone: its git dir {lone / '.git'} is gone; pass --repo", result.stderr)
result = self.hotfix("update", "lone", "--status", "merged", "--sha", sha, "--repo", str(self.other))
self.assertEqual(result.returncode, 2)
self.assertIn("is another repository than hotfix/lone's", result.stderr)
self.out("update", "lone", "--status", "merged", "--sha", sha, "--repo", str(self.seed))
row = self.rows()["hotfix/lone"]
self.assertEqual((row["common_dir"], row["stage"], row["sha"], row["pr"]), (str(self.seed / ".git"), "merged", sha, 7))
def squash_merged(self) -> tuple[str, str]:
self.git(self.seed, "switch", "-q", "-c", "fix/sq")
branch_sha = self.commit(self.seed, "sq.txt", "sq\n", "fix sq")
self.git(self.seed, "switch", "-q", "main")
self.out("add", "sq", *self.flags({"--branch": "fix/sq", "--sha": branch_sha, "--status": "pr",
"--target": "test-sq"}))
self.git(self.seed, "merge", "-q", "--squash", "fix/sq")
self.git(self.seed, "commit", "-q", "-m", "fix sq, squashed")
self.git(self.seed, "push", "-q", "origin", "main")
return branch_sha, self.head(self.seed)
def test_merged_needs_the_sha_as_it_sits_on_the_default_branch(self):
branch_sha, squashed = self.squash_merged()
result = self.hotfix("update", "sq", "--status", "merged")
self.assertEqual(result.returncode, 2)
self.assertIn("--status merged needs --sha", result.stderr)
for args in (
("update", "sq", "--status", "merged", "--sha", branch_sha),
("add", "sq2", *self.flags({"--branch": "fix/sq", "--sha": branch_sha, "--status": "merged"})),
):
with self.subTest(args=args[0]):
result = self.hotfix(*args)
self.assertEqual(result.returncode, 2)
self.assertIn(f"--sha {branch_sha} is not on origin/main", result.stderr)
self.assertEqual(self.rows()["hotfix/sq"]["stage"], "pr")
self.out("update", "sq", "--status", "merged", "--sha", squashed)
self.assertEqual(self.rows()["hotfix/sq"]["sha"], squashed)
def test_a_merged_sha_off_the_default_branch_warns_and_never_voids(self):
branch_sha, squashed = self.squash_merged()
self.out("update", "sq", "--status", "merged", "--sha", squashed)
rows = self.rows()
rows["hotfix/sq"]["sha"] = branch_sha
self.write_rows(rows)
for _ in range(2):
self.assertEqual(self.sync(self.work, "work/side").returncode, 0)
result = self.hotfix("void", "--repo", str(self.work), "--target", "test-sq")
self.assertEqual((result.returncode, result.stdout), (0, ""))
self.assertIn(f"hotfix/sq: {branch_sha} is not on origin/main", result.stderr)
result = self.hotfix("pending", "--repo", str(self.work))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(f"not on origin/main\thotfix/sq\tmerged\tfix/sq\t{branch_sha}\t-", result.stdout.splitlines())
def test_match_finds_hotfixes_by_their_signature(self):
origin = str(self.base / "origin")
self.assertEqual(
self.out("match", "--line", "psql: connection refused to db at port 5432"),
[f"hotfix/db\tmerged\tfix/db\t{self.fix}\t#12\t{origin}"],
)
self.assertEqual(
self.out("match", "--line", "ruff: E501 line too long", "--repo", str(self.work)),
[f"hotfix/lint\tpr\tfix/lint\t{self.lint}\t#13\t{origin}"],
)
result = self.hotfix("match", "--line", "all green")
self.assertEqual((result.returncode, result.stdout), (1, "hotfix: no signature matches\n"))
foreign = f"hotfix/other\tmerged\tfix/x\t{self.loose}\t-\t{self.base / 'other'}"
result = self.hotfix("match", "--line", "disk full on /var", "--repo", str(self.work))
self.assertEqual(
(result.returncode, result.stdout.splitlines()),
(1, [f"foreign\t{foreign}", "hotfix: no signature of this repository matches"]),
)
self.assertEqual(self.out("match", "--line", "disk full on /var", "--repo", str(self.other)), [foreign])
def test_add_refuses_only_a_signature_that_backtracks_without_end(self):
for pattern, reason in (("(a+)+", "repeats a group that repeats itself"),
("(a|a)*b", "repeats an alternation whose branches can start alike")):
with self.subTest(pattern=pattern):
result = self.hotfix("add", "nest", *self.flags({"--signature": pattern}))
self.assertEqual(result.returncode, 2)
self.assertIn(reason, result.stderr)
self.out("add", "plain", *self.flags({"--signature": r"(ab)+ timeout \d+"}))
self.out("add", "address", *self.flags({"--signature": r"no route to (\d{1,3}\.){3}\d{1,3}"}))
def test_match_names_a_signature_that_runs_too_long_and_searches_the_rest(self):
rows = self.rows()
rows["hotfix/slow"] = {**rows["hotfix/lint"], "signature": "(x+x+)+y"}
rows["hotfix/tail"] = {**rows["hotfix/lint"], "signature": "x{40}"}
self.write_rows(rows)
began = time.monotonic()
result = self.hotfix("match", "--line", "ruff: E501 " + "x" * 40, "--repo", str(self.work))
self.assertLess(time.monotonic() - began, 30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.splitlines(), [f"{task}\tpr\tfix/lint\t{self.lint}\t#13\t{self.base / 'origin'}"
for task in ("hotfix/lint", "hotfix/tail")])
self.assertIn("hotfix/slow: signature '(x+x+)+y' still searched this line at the 3 s deadline, skipped",
result.stderr)
def test_void_only_for_a_missing_merged_hotfix_of_the_targets(self):
result = self.hotfix("void", "--repo", str(self.work), "--target", "test", "--target", "test-deploy")
self.assertEqual((result.returncode, result.stdout), (3, f"void\thotfix/db\t{self.fix}\ttest-deploy\n"))
result = self.hotfix("void", "--repo", str(self.work), "--target", "test-lint")
self.assertEqual((result.returncode, result.stdout), (0, ""))
self.assertEqual(self.sync(self.work, "work/side").returncode, 0)
self.assertEqual(self.hotfix("void", "--repo", str(self.work), "--target", "test-deploy").returncode, 0)
self.git(self.seed, "switch", "-q", "-c", "fix/any")
anything = self.commit(self.seed, "any.txt", "any\n", "fix any")
self.git(self.seed, "push", "-q", "origin", "HEAD:main")
self.out("add", "any", *self.flags({"--branch": "fix/any", "--sha": anything, "--status": "merged"}))
for target in ("test-lint", "deploy"):
with self.subTest(target=target):
result = self.hotfix("void", "--repo", str(self.work), "--target", target)
self.assertEqual((result.returncode, result.stdout), (3, f"void\thotfix/any\t{anything}\tall targets\n"))
def test_test_star_targets_void_the_umbrella_and_a_deploy_needs_deploy(self):
result = self.hotfix("void", "--repo", str(self.work), "--target", "test")
self.assertEqual((result.returncode, result.stdout), (3, f"void\thotfix/db\t{self.fix}\ttest-deploy\n"))
self.assertEqual(self.hotfix("void", "--repo", str(self.work), "--target", "deploy").returncode, 0)
self.git(self.seed, "switch", "-q", "-c", "fix/deploy")
rollout = self.commit(self.seed, "deploy.txt", "deploy\n", "fix deploy")
self.git(self.seed, "push", "-q", "origin", "HEAD:main")
self.out("add", "deploy", *self.flags({"--branch": "fix/deploy", "--sha": rollout, "--status": "merged",
"--target": "deploy"}))
result = self.hotfix("void", "--repo", str(self.work), "--target", "deploy")
self.assertEqual((result.returncode, result.stdout), (3, f"void\thotfix/deploy\t{rollout}\tdeploy\n"))
result = self.hotfix("void", "--repo", str(self.work), "--target", "test-lint")
self.assertEqual((result.returncode, result.stdout), (0, ""))
def test_sync_merges_main_then_the_base_chain_and_skips_contained_refs(self):
hooks = self.work / ".git" / "hooks"
for hook in ("pre-merge-commit", "commit-msg"):
(hooks / hook).write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
(hooks / hook).chmod(0o755)
result = self.sync(self.work, "work/side")
self.assertEqual(self.merges(result), [["merged", "origin/main"], ["merged", "line"]])
self.assertEqual(result.stdout.splitlines()[1].split("\t")[2], self.head(self.work))
for name in ("db.txt", "line2.txt", "side.txt"):
self.assertTrue((self.work / name).is_file(), name)
self.assertEqual(self.git(self.work, "rev-list", "--count", "--merges", "HEAD").strip(), "2")
self.assertEqual(self.merges(self.sync(self.work, "work/side")), [["contained", "origin/main"],
["contained", "line"]])
result = self.sync(self.feature, "work/feature")
self.assertEqual((result.returncode, result.stdout), (0, f"merged\torigin/main\t{self.fix}\n"), result.stderr)
def test_the_base_chain_follows_only_open_rows_of_this_repository(self):
self.git(self.other, "branch", "line")
gone = self.base / "gone"
self.rasci("set", "work/gui", "--branch", "line", "--base", "feature", "--workdir", str(self.other))
self.rasci("set", "work/old", "--branch", "line", "--base", "fix/db", "--workdir", str(self.work),
"--status", "done")
self.rasci("set", "work/ghost", "--branch", "line", "--base", "feature", "--workdir", str(gone))
result = self.sync(self.work, "work/side")
self.assertEqual(self.merges(result), [["merged", "origin/main"], ["merged", "line"]])
self.assertIn(f"work/ghost: workdir {gone} is no git repository, its base is not followed", result.stderr)
def test_a_chain_row_without_workdir_counts_when_its_branch_resolves_here(self):
self.rasci("set", "work/line", "--workdir", "", "--base", "feature")
self.assertEqual(self.merges(self.sync(self.work, "work/side")),
[["merged", "origin/main"], ["merged", "line"], ["contained", "feature"]])
self.rasci("set", "work/up", "--branch", "nowhere", "--base", "main")
self.rasci("set", "work/side", "--base", "nowhere")
before = self.head(self.work)
result = self.sync(self.work, "work/side")
self.assertEqual((result.returncode, result.stdout), (2, ""))
self.assertIn("work/up records no workdir and its branch nowhere resolves neither as nowhere nor as "
f"origin/nowhere in {self.work}; record it with `rasci.py set work/up --workdir <dir>`",
result.stderr)
self.assertEqual(self.head(self.work), before)
def test_sync_without_an_origin_merges_local_branches_and_skips_the_fetch(self):
lone = self.base / "lone"
lone.mkdir()
self.git(lone, "init", "-q", "-b", "main")
self.commit(lone, "a.txt", "a\n", "init")
self.git(lone, "switch", "-q", "-c", "line")
self.commit(lone, "l1.txt", "l1\n", "line one")
self.git(lone, "switch", "-q", "-c", "feat")
self.commit(lone, "f.txt", "f\n", "feat")
self.git(lone, "switch", "-q", "line")
self.commit(lone, "l2.txt", "l2\n", "line two")
self.git(lone, "switch", "-q", "main")
self.commit(lone, "m.txt", "m\n", "main two")
self.git(lone, "switch", "-q", "feat")
self.rasci("set", "work/lone", "--branch", "feat", "--base", "line", "--workdir", str(lone))
self.rasci("set", "work/loneline", "--branch", "line", "--base", "main", "--workdir", str(lone))
result = self.sync(lone, "work/lone")
self.assertEqual(result.returncode, 2)
self.assertIn(f"{lone} has no origin remote; pass --default <branch>", result.stderr)
self.assertEqual(self.merges(self.sync(lone, "work/lone", "--default", "main")),
[["merged", "main"], ["merged", "line"]])
for name in ("m.txt", "l2.txt", "f.txt"):
self.assertTrue((lone / name).is_file(), name)
def test_relative_origins_resolve_against_each_worktree(self):
clones = []
for name in ("p1", "p2"):
clone = self.base / name / "app"
clone.mkdir(parents=True)
self.git(clone, "init", "-q", "-b", "main")
self.git(clone, "remote", "add", "origin", "../up")
clones.append((clone, self.commit(clone, "a.txt", f"{name}\n", "init")))
(one, sha), (two, _) = clones
self.out("add", "rel", *self.flags({"--repo": str(one), "--branch": "fix/rel", "--sha": sha,
"--signature": "relative boom"}))
self.assertEqual(self.rows()["hotfix/rel"]["origin"], str(self.base / "p1" / "up"))
found = f"hotfix/rel\tstaged\tfix/rel\t{sha}\t-\t{self.base / 'p1' / 'up'}"
self.assertEqual(self.out("match", "--line", "relative boom", "--repo", str(one)), [found])
result = self.hotfix("match", "--line", "relative boom", "--repo", str(two))
self.assertEqual((result.returncode, result.stdout.splitlines()[0]), (1, f"foreign\t{found}"))
def test_another_origin_in_the_same_git_dir_is_another_repository(self):
lint = f"hotfix/lint\tpr\tfix/lint\t{self.lint}\t#13\t{self.base / 'origin'}"
self.assertEqual(self.out("match", "--line", "ruff: E501", "--repo", str(self.seed)), [lint])
self.git(self.seed, "remote", "set-url", "origin", str(self.base / "elsewhere.git"))
result = self.hotfix("match", "--line", "ruff: E501", "--repo", str(self.seed))
self.assertEqual((result.returncode, result.stdout.splitlines()[0]), (1, f"foreign\t{lint}"))
def test_origins_apply_insteadof_and_fold_case_on_caseless_hosts(self):
first = self.base / "gh1"
first.mkdir()
self.git(first, "init", "-q", "-b", "main")
self.git(first, "config", "url.https://github.com/.insteadOf", "gh:")
self.git(first, "remote", "add", "origin", "gh:Org/App.git")
sha = self.commit(first, "a.txt", "a\n", "init")
self.out("add", "gh", *self.flags({"--repo": str(first), "--branch": "fix/gh", "--sha": sha,
"--signature": "github boom"}))
self.assertEqual(self.rows()["hotfix/gh"]["origin"], "github.com/org/app")
second = self.base / "gh2"
self.git(self.base, "clone", "-q", str(first), str(second))
self.git(second, "remote", "set-url", "origin", "https://GitHub.com/ORG/app/")
self.assertEqual(self.out("match", "--line", "github boom", "--repo", str(second)),
[f"hotfix/gh\tstaged\tfix/gh\t{sha}\t-\tgithub.com/org/app"])
def test_pending_filters_by_targets_as_void_does(self):
lint = f"open, not merged\thotfix/lint\tpr\tfix/lint\t{self.lint}\t#13"
result = self.hotfix("pending", "--repo", str(self.work), "--target", "test-lint")
self.assertEqual((result.returncode, result.stdout.splitlines()), (0, [lint, "hotfix: 0 missing, 1 open"]))
result = self.hotfix("pending", "--repo", str(self.work), "--target", "deploy")
self.assertEqual((result.returncode, result.stdout.splitlines()), (0, ["hotfix: 0 missing, 0 open"]))
result = self.hotfix("pending", "--repo", str(self.work), "--target", "test")
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn(f"missing (merge main)\thotfix/db\tmerged\tfix/db\t{self.fix}\t#12", result.stdout)
def test_a_base_merges_from_whichever_of_origin_and_local_contains_the_other(self):
self.git(self.work, "push", "-q", "origin", "line")
upstream = self.git(self.work, "commit-tree", "line^{tree}", "-p", "line", "-m", "upstream").strip()
self.git(self.work, "push", "-q", "origin", f"{upstream}:refs/heads/line")
self.assertEqual(self.merges(self.sync(self.work, "work/side")),
[["merged", "origin/main"], ["merged", "origin/line"]])
self.assertTrue(self.contains(self.work, upstream))
ahead = self.git(self.work, "commit-tree", f"{upstream}^{{tree}}", "-p", upstream, "-m", "ahead").strip()
self.git(self.work, "update-ref", "refs/heads/line", ahead)
self.assertEqual(self.merges(self.sync(self.work, "work/side")),
[["contained", "origin/main"], ["merged", "line"]])
apart = self.git(self.work, "commit-tree", f"{upstream}^{{tree}}", "-p", upstream, "-m", "apart").strip()
self.git(self.work, "push", "-q", "origin", f"{apart}:refs/heads/line")
before = self.head(self.work)
result = self.sync(self.work, "work/side")
self.assertEqual((result.returncode, result.stdout), (2, ""))
self.assertIn("line and origin/line have diverged", result.stderr)
self.assertEqual(self.head(self.work), before)
self.git(self.work, "branch", "-D", "line")
self.assertEqual(self.merges(self.sync(self.work, "work/side")),
[["contained", "origin/main"], ["merged", "origin/line"]])
self.assertTrue(self.contains(self.work, apart))
def test_sync_aborts_the_first_conflicting_merge_with_exit_4(self):
line = self.base / "line"
self.git(self.work, "worktree", "add", "-q", str(line), "line")
self.commit(line, "side.txt", "other\n", "clashing line work")
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 4, result.stderr)
self.assertEqual([row.split("\t")[0] for row in result.stdout.splitlines()], ["merged", "conflict"])
self.assertEqual(result.stdout.splitlines()[1], "conflict\tline\tside.txt")
self.assertIn("merging line conflicts in side.txt; merge aborted", result.stderr)
self.assertFalse((self.work / ".git" / "MERGE_HEAD").exists())
self.assertEqual(self.git(self.work, "status", "--porcelain", "--untracked-files=no"), "")
self.assertTrue(self.contains(self.work, self.fix))
self.assertFalse(self.contains(self.work, self.head(line)))
def test_a_merge_failing_without_a_conflict_exits_2_with_gits_message(self):
before = self.head(self.work)
(self.work / "db.txt").write_text("untracked\n", encoding="utf-8")
result = self.sync(self.work, "work/side")
self.assertEqual((result.returncode, result.stdout), (2, ""))
self.assertIn("untracked working tree files would be overwritten by merge", result.stderr)
(self.work / "db.txt").unlink()
self.git(self.work, "config", "commit.gpgsign", "true")
self.git(self.work, "config", "gpg.program", "false")
result = self.sync(self.work, "work/side")
self.assertEqual((result.returncode, result.stdout), (2, ""))
self.assertIn("gpg failed to sign the data", result.stderr)
self.assertFalse((self.work / ".git" / "MERGE_HEAD").exists())
self.assertEqual(self.git(self.work, "status", "--porcelain", "--untracked-files=no"), "")
self.assertEqual(self.head(self.work), before)
def test_sync_needs_the_tasks_branch_checked_out(self):
before = self.head(self.work)
self.git(self.work, "switch", "-q", "--detach")
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 2)
self.assertIn(f"{self.work} has a detached HEAD checked out, work/side works on sidetree", result.stderr)
self.git(self.work, "switch", "-q", "line")
line = self.head(self.work)
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 2)
self.assertIn(f"{self.work} has line checked out, work/side works on sidetree", result.stderr)
self.assertEqual((self.git(self.work, "rev-parse", "sidetree").strip(), self.head(self.work)), (before, line))
self.rasci("set", "work/loose")
self.assertIn("work/loose records no branch", self.sync(self.work, "work/loose").stderr)
def test_sync_refuses_while_a_gate_or_a_busy_process_holds_the_worktree(self):
before = self.head(self.work)
git_dir = Path(self.git(self.work, "rev-parse", "--absolute-git-dir").strip())
with open(git_dir / RUN_LOCK, "a", encoding="utf-8") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 2)
self.assertIn(f"a gate.py run or hold holds {self.work}", result.stderr)
config = self.base / "config.json"
config.write_text(json.dumps({"busy_processes": ["sleep"]}), encoding="utf-8")
self.env["AGENTS_COUNCIL_CONFIG"] = str(config)
deploy = subprocess.Popen(["sleep", "60"], cwd=self.work)
self.addCleanup(deploy.wait)
self.addCleanup(deploy.kill)
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 2)
self.assertIn(f"a busy process (watch.py busy_processes) runs in {self.work}", result.stderr)
self.assertEqual(self.head(self.work), before)
deploy.kill()
deploy.wait()
self.assertEqual(self.sync(self.work, "work/side").returncode, 0)
def wait_for(self, path: Path, process: subprocess.Popen) -> None:
deadline = time.monotonic() + 30
while not path.exists():
if process.poll() is not None:
self.fail(f"exited {process.returncode} before {path} appeared: {process.stderr.read()}")
self.assertLess(time.monotonic(), deadline, f"{path} never appeared")
time.sleep(0.05)
def kill(self, pid: int) -> None:
with contextlib.suppress(ProcessLookupError):
os.kill(pid, signal.SIGKILL)
def lock_free(self, lock: Path) -> bool:
with open(lock, "a", encoding="utf-8") as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return False
return True
def test_sync_refuses_while_an_orphan_of_gate_py_hold_runs_where_no_process_check_sees_it(self):
before = self.head(self.work)
pid_file, cwd_file = self.base / "deploy.pid", self.base / "deploy.cwd"
holder = subprocess.Popen(
[sys.executable, str(GATE), "hold", "--repo", str(self.work), "--",
"sh", "-c", f"pwd > {cwd_file}; echo $$ > {pid_file}.tmp; mv {pid_file}.tmp {pid_file}; exec sleep 60"],
env=self.env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
)
self.addCleanup(holder.stderr.close)
self.addCleanup(holder.wait)
self.addCleanup(holder.kill)
self.wait_for(pid_file, holder)
deploy = int(pid_file.read_text(encoding="utf-8"))
self.addCleanup(self.kill, deploy)
self.assertEqual(cwd_file.read_text(encoding="utf-8").strip(), str(self.work))
holder.kill()
holder.wait()
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 2, result.stdout)
self.assertIn(f"a gate.py run or hold holds {self.work} (pid {holder.pid}: "
f"gate.py hold --repo {self.work} -- sh", result.stderr)
self.assertEqual(self.head(self.work), before)
lock = Path(self.git(self.work, "rev-parse", "--absolute-git-dir").strip()) / RUN_LOCK
os.kill(deploy, signal.SIGKILL)
deadline = time.monotonic() + 30
while not self.lock_free(lock):
self.assertLess(time.monotonic(), deadline, "the killed deploy kept the lock")
time.sleep(0.05)
self.assertEqual(self.sync(self.work, "work/side").returncode, 0)
def test_sync_fetches_before_the_locks_and_holds_them_through_every_merge_and_abort(self):
line = self.base / "line"
self.git(self.work, "worktree", "add", "-q", str(line), "line")
self.commit(line, "side.txt", "other\n", "clashing line work")
lock = Path(self.git(self.work, "rev-parse", "--absolute-git-dir").strip()) / RUN_LOCK
probe, calls = self.base / "probe.py", self.base / "lock-states.log"
probe.write_text(PROBE, encoding="utf-8")
wrapper = self.base / "bin" / "git"
wrapper.parent.mkdir()
real = shutil.which("git", path=self.env["PATH"])
wrapper.write_text(
"#!/bin/sh\n"
f"case \" $* \" in *\" fetch \"*|*\" merge \"*) {sys.executable} {probe} {lock} \"$*\" >> {calls} ;; esac\n"
f"exec {real} \"$@\"\n",
encoding="utf-8",
)
wrapper.chmod(0o755)
traced = {**self.env, "PATH": f"{wrapper.parent}:{self.env['PATH']}"}
result = self.hotfix("sync", "--repo", str(self.work), "--task", "work/side", env=traced)
self.assertEqual(result.returncode, 4, result.stderr)
states = [entry.split(" ", 1) for entry in calls.read_text(encoding="utf-8").splitlines()]
commands = [command.split(" ")[2] + (" --abort" if command.endswith("--abort") else "") for _, command in states]
self.assertEqual(commands, ["fetch", "merge", "merge", "merge --abort"])
self.assertEqual([state for state, _ in states], ["free", "held", "held", "held"], states)
self.assertTrue(self.lock_free(lock))
self.assertIn(f"\thotfix.py sync --repo {self.work} --task work/side\n",
lock.with_name(SYNC_LOCK).read_text(encoding="utf-8"))
def test_sync_and_update_keep_git_maintenance_off(self):
calls = self.base / "git-calls.log"
wrapper = self.base / "bin" / "git"
wrapper.parent.mkdir()
real = shutil.which("git", path=self.env["PATH"])
wrapper.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {calls}\nexec {real} \"$@\"\n", encoding="utf-8")
wrapper.chmod(0o755)
traced = {**self.env, "PATH": f"{wrapper.parent}:{self.env['PATH']}"}
self.assertEqual(self.hotfix("sync", "--repo", str(self.work), "--task", "work/side", env=traced).returncode, 0)
result = self.hotfix("update", "lint", "--status", "merged", "--sha", self.fix, env=traced)
self.assertEqual(result.returncode, 0, result.stderr)
lines = calls.read_text(encoding="utf-8").splitlines()
self.assertTrue(any(line.startswith("-c maintenance.auto=false merge ") for line in lines), lines)
self.assertEqual([line for line in lines if not line.startswith("-c maintenance.auto=false ")], [])
fetches = [line for line in lines if " fetch " in f" {line} "]
self.assertEqual(len(fetches), 2, lines)
self.assertTrue(all("--no-auto-maintenance" in line for line in fetches), fetches)
def test_sync_needs_a_clean_tree_and_names_the_default_branch_explicitly(self):
before = self.head(self.work)
(self.work / "side.txt").write_text("changed\n", encoding="utf-8")
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 2)
self.assertIn("tracked files are dirty, commit them first: side.txt", result.stderr)
self.assertEqual(self.head(self.work), before)
(self.work / "side.txt").write_text("side\n", encoding="utf-8")
(self.work / "notes.txt").write_text("untracked\n", encoding="utf-8")
self.git(self.work, "remote", "set-head", "origin", "-d")
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 2)
self.assertIn("pass --default <branch>", result.stderr)
self.assertEqual(self.head(self.work), before)
self.assertEqual(self.sync(self.work, "work/side", "--default", "main").returncode, 0)
self.git(self.work, "remote", "set-head", "origin", "main")
result = self.sync(self.work, "work/side", "--default", "line")
self.assertEqual(result.returncode, 2)
self.assertIn("origin/HEAD names main, --default says line", result.stderr)
def test_invalid_input_exits_2_and_leaves_the_register_alone(self):
register = self.root / "rasci.json"
before = register.read_text(encoding="utf-8")
for args in (
("add", "Bad Slug", *self.flags({})),
("add", "db", *self.flags({})),
("add", "x", *self.flags({"--repo": "seed"})),
("add", "x", *self.flags({"--repo": str(self.base / "home")})),
("add", "x", *self.flags({"--sha": "HEAD"})),
("add", "x", *self.flags({"--sha": "0" * 40})),
("add", "x", *self.flags({"--signature": "("})),
("add", "x", *self.flags({"--signature": " "})),
("add", "x", *self.flags({"--signature": "(a+)+"})),
("add", "x", *self.flags({"--branch": "a..b"})),
("add", "x", *self.flags({"--status": "done"})),
("add", "x", *self.flags({"--status": None})),
("add", "x", *self.flags({"--pr": "0"})),
("add", "x", *self.flags({"--target": "a b"})),
("update", "none", "--status", "pr"),
("update", "db", "--status", "merged", "--sha", "zzz"),
("update", "db", "--status", "merged"),
("update", "db", "--status", "pr", "--repo", "seed"),
("update", "db"),
("pending", "--repo", str(self.base / "absent")),
("pending", "--repo", str(self.base / "home")),
("match", "--line", "x", "--repo", str(self.base / "absent")),
("void", "--repo", str(self.work)),
("match",),
("sync", "--repo", str(self.work), "--task", "work/none"),
):
with self.subTest(args=args):
self.assertEqual(self.hotfix(*args).returncode, 2)
self.assertEqual(register.read_text(encoding="utf-8"), before)
self.rasci("set", "work/twin", "--branch", "line", "--base", "feature", "--workdir", str(self.feature))
result = self.sync(self.work, "work/side")
self.assertEqual(result.returncode, 2)
self.assertIn("branch line has several bases in the register: work/line=main, work/twin=feature", result.stderr)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -151,6 +151,15 @@ class TestRasci(unittest.TestCase):
self._run("set", "work/a", "--status", "running")
self.assertFalse(any(line.startswith("end:") for line in self._out("get", "work/a")))
def test_base_names_the_upstream_branch_after_the_branch(self):
self._run("set", "work/side", "--branch", "sidetree", "--base", "line")
lines = self._out("get", "work/side")
self.assertIn("base: line", lines)
self.assertEqual(lines.index("base: line"), lines.index("branch: sidetree") + 1)
self.assertEqual(json.loads((self.root / "rasci.json").read_text(encoding="utf-8"))["work/side"]["base"], "line")
self._run("set", "work/side", "--base", "")
self.assertFalse(any(line.startswith("base:") for line in self._out("get", "work/side")))
def test_done_tasks_show_only_with_all(self):
self._run("set", "work/a", "--status", "done", "--end", "2026-11-01T12:00:00+00:00")
self._run("role", "work/a", "Skills", "RA")
@@ -279,6 +288,28 @@ class TestRasci(unittest.TestCase):
self._run("estimate", "work/b", "One", "--drop")
self.assertFalse(any(line.startswith("estimate ") for line in self._out("get", "work/b")))
def test_hotfix_and_operator_rows_are_no_work_tasks_for_gaps_and_wsjf(self):
self._run("set", "work/a")
self._run("role", "work/a", "Skills", "RA")
self._run("kr", "work/a", "add", "released")
self._run("set", "hotfix/db", "--status", "running")
self._run("set", "operator/push", "--status", "running")
shown = self._out("show")
self.assertEqual([line.split(" | ")[2] for line in shown[2:]], ["hotfix/db", "operator/push", "work/a"])
self.assertFalse(any(line.startswith("gaps:") for line in shown))
self.assertEqual(
self._out("wsjf")[2:], ["| 1 | work/a | - (0/1 KR) | - | - | - | - | queued |", "unestimated: work/a"]
)
self._run("set", "work/b", "--title", "Beta")
self.assertEqual(self._out("show")[-1], "gaps: work/b has 0 A; work/b has no R; work/b has no key result")
self._run("set", "hotfix/db", "--status", "done", "--branch", "fix/db", "--workdir", "/w/db")
self._run("set", "operator/push", "--branch", "main", "--artifact", "dir:/tmp/push")
self._run("set", "work/a", "--branch", "feat/a")
for args in (("artifacts",), ("artifacts", "--ended"), ("artifacts", "--task", "hotfix/db")):
with self.subTest(args=args):
self.assertEqual([line.split("\t")[1] for line in self._out(*args)],
["work/a"] if args == ("artifacts",) else [])
def test_order_pins_the_agents_in_the_operators_order(self):
self._run("set", "work/a1", "--worker", "Alpha", "--prio", "1")
self._run("set", "work/a2", "--worker", "Alpha", "--prio", "2")